glove/scripts/preflight.sh
2026-08-14 20:13:48 -06:00

271 lines
11 KiB
Bash
Executable file

#!/usr/bin/env bash
# preflight.sh — gates that must pass before pushing.
#
# Runs:
# 1. actionlint across GitHub Actions workflows
# 2. clang-format --dry-run -Werror across src/ include/ tests/ benchmarks/ fuzz/
# 3. clang-tidy on the dev compile_commands.json (if available)
# 4. asan preset: configure, build, ctest
# 5. tsan preset: configure, build, ctest
#
# Exits non-zero on the first failure. Re-run with --skip-tidy to bypass tidy
# locally if it is not installed.
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT}"
skip_tidy=0
required_actionlint_version="1.7.12"
required_clang_format_version="22.1.8"
for arg in "$@"; do
case "${arg}" in
--skip-tidy) skip_tidy=1 ;;
*) echo "unknown arg: ${arg}" >&2; exit 2 ;;
esac
done
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
fail() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
ok() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
prepare_linux_userns_tests() {
if [[ "${GLOVE_PREPARE_LINUX_USERNS:-0}" == "1" ]]; then
if [[ "$(uname -s)" != "Linux" ]]; then
fail "GLOVE_PREPARE_LINUX_USERNS is supported only on Linux"
fi
if ! command -v sudo >/dev/null; then
fail "GLOVE_PREPARE_LINUX_USERNS requires sudo"
fi
if [[ ! -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
fail "AppArmor unprivileged-userns control is unavailable"
fi
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
}
find_fuzzer_compiler() {
local candidate=""
if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null; then
local formula=""
local prefix=""
# LLVM 19+ can emit libFuzzer Mach-O relocations rejected by Apple's
# linker. Prefer the CI-pinned compatible toolchain when available.
for formula in llvm@18 llvm; do
if ! prefix="$(brew --prefix "${formula}" 2>/dev/null)"; then
continue
fi
candidate="${prefix}/bin/clang++"
if [[ -x "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return
fi
done
fi
for candidate in clang++-22 clang++-21 clang++-20 clang++-19 clang++-18 clang++; do
if command -v "${candidate}" >/dev/null; then
command -v "${candidate}"
return
fi
done
fail "a Clang compiler with libFuzzer support is required"
}
# 1. GitHub Actions ---------------------------------------------------------
bold "[1/5] actionlint"
if ! command -v actionlint >/dev/null; then
fail "actionlint not on PATH"
fi
actionlint_version="$(actionlint -version | sed -n '1{s/^v//;p;}')"
if [[ "${actionlint_version}" != "${required_actionlint_version}" ]]; then
fail "actionlint ${required_actionlint_version} required; found ${actionlint_version}"
fi
actionlint
sh -n setup.sh
ok "GitHub Actions workflows clean"
# 2. format -----------------------------------------------------------------
bold "[2/5] clang-format --dry-run"
if ! command -v clang-format >/dev/null; then
fail "clang-format not on PATH"
fi
clang_format_version="$(clang-format --version)"
if [[ "${clang_format_version}" != *"version ${required_clang_format_version}"* ]]; then
fail "clang-format ${required_clang_format_version} required; found ${clang_format_version}"
fi
fmt_files=()
while IFS= read -r -d '' f; do
fmt_files+=("${f}")
done < <(
find src include tests benchmarks fuzz -type f \( -name '*.cpp' -o -name '*.hpp' \) -print0 \
2>/dev/null
)
if [[ ${#fmt_files[@]} -eq 0 ]]; then
ok "no source files yet"
else
clang-format --dry-run -Werror "${fmt_files[@]}"
ok "format clean (${#fmt_files[@]} files)"
fi
# 3. tidy -------------------------------------------------------------------
bold "[3/5] clang-tidy"
tidy_bin=""
if [[ ${skip_tidy} -eq 1 ]]; then
echo " (skipped via --skip-tidy)"
else
if command -v clang-tidy >/dev/null; then
tidy_bin="$(command -v clang-tidy)"
elif command -v brew >/dev/null && [[ -x "$(brew --prefix llvm 2>/dev/null)/bin/clang-tidy" ]]; then
tidy_bin="$(brew --prefix llvm)/bin/clang-tidy"
fi
if [[ -z "${tidy_bin}" ]]; then
echo " clang-tidy not installed; skipping (install via 'brew install llvm' for the gate)"
else
# Benchmarks are opt-in for ordinary builds, but their source remains
# part of the style gate and must appear in the compilation database.
cmake --preset dev -DGLOVE_BUILD_BENCHMARKS=ON -DGLOVE_BUILD_FUZZERS=OFF >/dev/null
# Ninja's C++ dependency scanner writes module-map response files that
# appear in compile_commands.json. A fresh checkout must build once
# before clang-tidy can consume those generated arguments.
cmake --build --preset dev
# Apple clang's compile_commands omits -isysroot because the driver
# implicitly knows the SDK; homebrew clang-tidy doesn't, so tell it.
tidy_extra=()
if [[ "$(uname -s)" == "Darwin" ]] && command -v xcrun >/dev/null; then
sdk="$(xcrun --show-sdk-path)"
tidy_extra=(-extra-arg-before="-isysroot${sdk}")
fi
# Lint only translation units represented by the active CMake compile
# database. Headers are still analyzed through their owning sources,
# while platform-specific files absent from this host build are not
# parsed with an unrelated fallback command.
tidy_files=()
while IFS= read -r -d '' f; do
tidy_files+=("${f}")
done < <(
python3 - "${ROOT}" <<'PY'
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1]).resolve()
database = json.loads((root / "build/dev/compile_commands.json").read_text())
seen = set()
for entry in database:
source = pathlib.Path(entry["file"])
if not source.is_absolute():
source = pathlib.Path(entry["directory"]) / source
source = source.resolve()
try:
source.relative_to(root)
except ValueError:
continue
if source.suffix != ".cpp" or source in seen:
continue
seen.add(source)
sys.stdout.buffer.write(str(source).encode() + b"\0")
PY
)
if [[ ${#tidy_files[@]} -eq 0 ]]; then
fail "compile database contains no project translation units"
fi
tidy_runner="$(dirname "${tidy_bin}")/run-clang-tidy"
if [[ ! -x "${tidy_runner}" ]]; then
tidy_runner="$(dirname "${tidy_bin}")/run-clang-tidy.py"
fi
if [[ ! -x "${tidy_runner}" ]]; then
fail "run-clang-tidy not installed alongside ${tidy_bin}"
fi
# Two workers keep memory bounded on hosted runners while reducing the
# full Linux translation-unit pass from timeout-scale wall-clock time.
"${tidy_runner}" -j 2 -quiet -p build/dev -clang-tidy-binary "${tidy_bin}" \
"${tidy_extra[@]}" "${tidy_files[@]}"
ok "tidy clean (${tidy_bin})"
fi
fi
# 4. asan -------------------------------------------------------------------
bold "[4/5] asan preset"
prepare_linux_userns_tests
cmake --preset asan -DGLOVE_BUILD_FUZZERS=OFF
cmake --build --preset asan
asan_opts="halt_on_error=1:abort_on_error=1"
if [[ "$(uname -s)" != "Darwin" ]]; then
# LSan ships with ASan on Linux but not on Apple platforms.
asan_opts="${asan_opts}:detect_leaks=1"
fi
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
ctest --preset asan
fuzzer_cxx="$(find_fuzzer_compiler)"
fuzzer_symbolizer="$(dirname "${fuzzer_cxx}")/llvm-symbolizer"
if [[ -x "${fuzzer_symbolizer}" ]]; then
export ASAN_SYMBOLIZER_PATH="${fuzzer_symbolizer}"
fi
cmake --preset fuzz \
-DCMAKE_CXX_COMPILER="${fuzzer_cxx}" \
-DFETCHCONTENT_SOURCE_DIR_GLAZE="${ROOT}/build/dev/_deps/glaze-src"
cmake --build --preset fuzz
fuzz_workspace="$(mktemp -d "${TMPDIR:-/tmp}/glove-fuzz-corpora.XXXXXX")"
trap 'rm -rf -- "${fuzz_workspace}"' EXIT
mcp_fuzz_corpus="${fuzz_workspace}/mcp_codec"
policy_fuzz_corpus="${fuzz_workspace}/policy_jsonpath"
manifest_fuzz_corpus="${fuzz_workspace}/change_manifest"
session_plan_fuzz_corpus="${fuzz_workspace}/session_plan"
journal_fuzz_corpus="${fuzz_workspace}/change_apply_journal"
bundle_fuzz_corpus="${fuzz_workspace}/library_bundle"
cp -R fuzz/corpus/mcp_codec "${mcp_fuzz_corpus}"
cp -R fuzz/corpus/policy_jsonpath "${policy_fuzz_corpus}"
cp -R fuzz/corpus/change_manifest "${manifest_fuzz_corpus}"
cp -R fuzz/corpus/session_plan "${session_plan_fuzz_corpus}"
cp -R fuzz/corpus/change_apply_journal "${journal_fuzz_corpus}"
cp -R fuzz/corpus/library_bundle "${bundle_fuzz_corpus}"
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
build/fuzz/fuzz/glove_mcp_codec_fuzzer \
-runs=10000 -max_len=65536 -timeout=5 -rss_limit_mb=2048 -verbosity=0 \
-artifact_prefix="${mcp_fuzz_corpus}/" \
"${mcp_fuzz_corpus}"
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
build/fuzz/fuzz/glove_policy_jsonpath_fuzzer \
-runs=10000 -max_len=65536 -timeout=5 -rss_limit_mb=2048 -verbosity=0 \
-artifact_prefix="${policy_fuzz_corpus}/" \
"${policy_fuzz_corpus}"
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
build/fuzz/fuzz/glove_change_manifest_fuzzer \
-runs=10000 -max_len=65536 -timeout=5 -rss_limit_mb=2048 -verbosity=0 \
-artifact_prefix="${manifest_fuzz_corpus}/" \
"${manifest_fuzz_corpus}"
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
build/fuzz/fuzz/glove_session_plan_fuzzer \
-runs=10000 -max_len=65536 -timeout=5 -rss_limit_mb=2048 -verbosity=0 \
-artifact_prefix="${session_plan_fuzz_corpus}/" \
"${session_plan_fuzz_corpus}"
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
build/fuzz/fuzz/glove_change_apply_journal_fuzzer \
-runs=10000 -max_len=65536 -timeout=5 -rss_limit_mb=2048 -verbosity=0 \
-artifact_prefix="${journal_fuzz_corpus}/" \
"${journal_fuzz_corpus}"
ASAN_OPTIONS="${asan_opts}" \
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" \
build/fuzz/fuzz/glove_library_bundle_fuzzer \
-runs=10000 -max_len=65536 -timeout=5 -rss_limit_mb=2048 -verbosity=0 \
-artifact_prefix="${bundle_fuzz_corpus}/" \
"${bundle_fuzz_corpus}"
ok "asan ok"
# 5. tsan -------------------------------------------------------------------
bold "[5/5] tsan preset"
cmake --preset tsan -DGLOVE_BUILD_FUZZERS=OFF
cmake --build --preset tsan
TSAN_OPTIONS="halt_on_error=1:second_deadlock_stack=1" \
ctest --preset tsan
ok "tsan ok"
bold "all gates passed"