Created
September 9, 2025 19:41
-
-
Save xpepper/8f8d8a5e991138d2e861842ba93c386f to your computer and use it in GitHub Desktop.
A script to run a CI-like check on a multi-create Rust repository
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # Local mirror of CI steps (fmt, clippy, build, docs, tests) per crate. | |
| # Usage: ./ci-local.sh [options] | |
| # Options: | |
| # -a, --all Run for all crates (ignore git diff detection) | |
| # -b, --base <ref> Base git ref to compare against (default: origin/master) | |
| # -f, --fix Apply formatting instead of just checking | |
| # -t, --skip-tests Skip tests | |
| # -d, --skip-docs Skip doc build | |
| # -n, --no-deny Skip cargo deny | |
| # -q, --quiet Less verbose output | |
| # --list Only list selected crates and exit | |
| # -h, --help Show help | |
| # Env: | |
| # RUST_TOOLCHAIN (default 1.89) | |
| # NEXTEST_PROFILE (default ci) | |
| # NEXTEST_NO_TESTS (default warn) | |
| RUST_TOOLCHAIN=${RUST_TOOLCHAIN:-1.89} | |
| BASE_REF=origin/master | |
| ALL_CRATES=false | |
| FIX_FMT=false | |
| SKIP_TESTS=false | |
| SKIP_DOCS=false | |
| SKIP_DENY=false | |
| QUIET=false | |
| LIST_ONLY=false | |
| log() { $QUIET && return 0; printf "\033[1;34m[ci-local]\033[0m %s\n" "$*"; } | |
| err() { printf "\033[1;31m[ci-local][error]\033[0m %s\n" "$*" >&2; } | |
| usage() { sed -n '1,40p' "$0" | sed 's/^# \{0,1\}//'; } | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -a|--all) ALL_CRATES=true ; shift ;; | |
| -b|--base) BASE_REF=$2 ; shift 2 ;; | |
| -f|--fix) FIX_FMT=true ; shift ;; | |
| -t|--skip-tests) SKIP_TESTS=true ; shift ;; | |
| -d|--skip-docs) SKIP_DOCS=true ; shift ;; | |
| -n|--no-deny) SKIP_DENY=true ; shift ;; | |
| -q|--quiet) QUIET=true ; shift ;; | |
| --list) LIST_ONLY=true ; shift ;; | |
| -h|--help) usage; exit 0 ;; | |
| *) err "Unknown option $1"; usage; exit 1 ;; | |
| esac | |
| done | |
| need_cmd() { command -v "$1" >/dev/null 2>&1 || { err "Missing required command: $1"; exit 1; }; } | |
| need_cmd git | |
| need_cmd cargo | |
| need_cmd find | |
| need_cmd sed | |
| # Optional tools (we check later): taplo, cargo-nextest, cargo-deny | |
| if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
| err "Not inside a git repository"; exit 1; fi | |
| git fetch --quiet --no-tags --depth=1 $(dirname "$BASE_REF") || true | |
| # Collect crate names via taplo (preferred) or fallback to parsing Cargo.toml 'name =' | |
| collect_crates() { | |
| local crates=() | |
| while IFS= read -rd '' manifest; do | |
| if command -v taplo >/dev/null 2>&1; then | |
| local name | |
| if name=$(taplo get package.name -f "$manifest" 2>/dev/null); then | |
| crates+=("$name") | |
| fi | |
| else | |
| local name_line name | |
| name_line=$(grep -E '^name\s*=\s*".*"' "$manifest" | head -n1 || true) | |
| name=$(echo "$name_line" | sed -E 's/name\s*=\s*"([^"]+)"/\1/') | |
| [[ -n $name ]] && crates+=("$name") | |
| fi | |
| done < <(find . -maxdepth 2 -type f -name Cargo.toml -print0) | |
| printf '%s\n' "${crates[@]}" | sort -u | |
| } | |
| map_crate_dir() { | |
| # given crate name, output directory path (top-level) containing its Cargo.toml | |
| local target=$1 | |
| while IFS= read -rd '' manifest; do | |
| local name | |
| if command -v taplo >/dev/null 2>&1; then | |
| name=$(taplo get package.name -f "$manifest" 2>/dev/null || echo '') | |
| else | |
| local line | |
| line=$(grep -E '^name\s*=\s*".*"' "$manifest" | head -n1 || true) | |
| name=$(echo "$line" | sed -E 's/name\s*=\s*"([^"]+)"/\1/') | |
| fi | |
| if [[ $name == "$target" ]]; then | |
| dirname "$manifest" | sed 's|^./||' | |
| return 0 | |
| fi | |
| done < <(find . -maxdepth 2 -type f -name Cargo.toml -print0) | |
| return 1 | |
| } | |
| ALL_CRATE_NAMES=($(collect_crates)) | |
| select_changed_crates() { | |
| local changed=() | |
| local diff_files | |
| diff_files=$(git diff --name-only "$BASE_REF"...HEAD || true) | |
| # If root Cargo.toml or workflow changed -> all crates | |
| if echo "$diff_files" | grep -Eq '(^|/)Cargo.toml$|^\.github/workflows/'; then | |
| printf '%s\n' "${ALL_CRATE_NAMES[@]}" | |
| return 0 | |
| fi | |
| for crate in "${ALL_CRATE_NAMES[@]}"; do | |
| local dir | |
| dir=$(map_crate_dir "$crate") || continue | |
| if echo "$diff_files" | grep -Eq "^${dir}/"; then | |
| changed+=("$crate") | |
| fi | |
| done | |
| if [[ ${#changed[@]} -eq 0 ]]; then | |
| # fallback: none changed | |
| return 0 | |
| fi | |
| printf '%s\n' "${changed[@]}" | |
| } | |
| if $ALL_CRATES; then | |
| TARGET_CRATES=(${ALL_CRATE_NAMES[@]}) | |
| else | |
| mapfile -t maybe_changed < <(select_changed_crates) | |
| if [[ ${#maybe_changed[@]} -eq 0 ]]; then | |
| log "No crates changed vs $BASE_REF (use --all to force)."; TARGET_CRATES=(); | |
| else | |
| TARGET_CRATES=(${maybe_changed[@]}) | |
| fi | |
| fi | |
| if $LIST_ONLY; then | |
| printf '%s\n' "${TARGET_CRATES[@]}"; exit 0; fi | |
| if [[ ${#TARGET_CRATES[@]} -eq 0 ]]; then | |
| log "Nothing to do."; exit 0; fi | |
| log "Crates: ${TARGET_CRATES[*]}" | |
| ensure_toolchain() { | |
| if ! rustc --version | grep -q "${RUST_TOOLCHAIN}"; then | |
| log "Installing toolchain ${RUST_TOOLCHAIN}"; | |
| rustup toolchain install "${RUST_TOOLCHAIN}" --component rustfmt clippy >/dev/null | |
| fi | |
| export RUSTUP_TOOLCHAIN="${RUST_TOOLCHAIN}" | |
| } | |
| ensure_toolchain | |
| run_deny() { | |
| $SKIP_DENY && return 0 | |
| if ! command -v cargo-deny >/dev/null 2>&1; then | |
| log "Installing cargo-deny"; cargo install cargo-deny >/dev/null | |
| fi | |
| log "cargo deny check"; cargo deny check | |
| } | |
| run_fmt() { | |
| local crate=$1 | |
| if $FIX_FMT; then | |
| cargo fmt --package "$crate" | |
| else | |
| cargo fmt --check --package "$crate" | |
| fi | |
| } | |
| run_clippy() { cargo clippy --all-features --all-targets --package "$1" -- -D warnings; } | |
| run_build_all() { cargo build --all-features --all-targets --package "$1"; } | |
| run_build_default() { cargo build --all-targets --package "$1"; } | |
| run_docs() { | |
| $SKIP_DOCS && return 0 | |
| RUSTDOCFLAGS=-Dwarnings cargo doc --document-private-items --all-features --no-deps --package "$1" | |
| } | |
| run_tests() { | |
| $SKIP_TESTS && return 0 | |
| export NEXTEST_PROFILE=${NEXTEST_PROFILE:-ci} | |
| export NEXTEST_NO_TESTS=${NEXTEST_NO_TESTS:-warn} | |
| if command -v cargo-nextest >/dev/null 2>&1; then | |
| cargo nextest run --all-features --all-targets --package "$1" | |
| else | |
| log "cargo-nextest missing, falling back to cargo test"; | |
| cargo test --all-features --all-targets --package "$1" | |
| fi | |
| } | |
| run_deny | |
| FAIL=0 | |
| for crate in "${TARGET_CRATES[@]}"; do | |
| log "==> $crate: fmt"; run_fmt "$crate" || { err "fmt failed: $crate"; FAIL=1; } | |
| log "==> $crate: clippy"; run_clippy "$crate" || { err "clippy failed: $crate"; FAIL=1; } | |
| log "==> $crate: build(all)"; run_build_all "$crate" || { err "build(all) failed: $crate"; FAIL=1; } | |
| log "==> $crate: build(def)"; run_build_default "$crate" || { err "build(def) failed: $crate"; FAIL=1; } | |
| log "==> $crate: docs"; run_docs "$crate" || { err "docs failed: $crate"; FAIL=1; } | |
| log "==> $crate: tests"; run_tests "$crate" || { err "tests failed: $crate"; FAIL=1; } | |
| done | |
| if [[ $FAIL -ne 0 ]]; then | |
| err "Some steps failed."; exit 1; fi | |
| log "All selected crates passed." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment