Skip to content

Instantly share code, notes, and snippets.

@xpepper
Created September 9, 2025 19:41
Show Gist options
  • Save xpepper/8f8d8a5e991138d2e861842ba93c386f to your computer and use it in GitHub Desktop.
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
#!/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