mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* ci: cancel superseded runs + per-job timeouts on test.yml & e2e.yml Ports the GH-Actions hygiene gbrain already uses in heavy-tests.yml to the two hot-path workflows. concurrency cancels a superseded run (keyed on PR number for pull_request events — fork-safe — with github.ref fallback for push/scheduled); frees runners and stops a stale-SHA run reporting a flaky failure on an obsolete commit. Per-job timeout-minutes (test matrix 15, verify 12, serial 15, slow-* 12, e2e tier1 20 / tier2 30, trivial jobs 5-10) convert a wedged job from a 6-hour zombie (GitHub's default) into a fast legible fail. fail-fast:false already set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: add actionlint workflow (rhysd/actionlint v1.7.11, SHA-pinned) Lints workflow YAML on .github/workflows/** changes so a malformed workflow / bad action ref / missing-permission bug is caught before it ships a broken pipeline. gbrain edits these workflows often; cheap preventive guard. Mirrors GStack's actionlint job, SHA-pinned to gbrain's convention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(e2e): scrub operator/agent env before E2E (hermetic runner) A dev or Conductor shell exports CONDUCTOR_*/MCP_*/OPENCLAW_*/GBRAIN_* overrides that silently change test behavior, making hermetic E2E non-hermetic and its failures unreproducible across machines. run-e2e.sh drops those prefixes before bun starts (denylist — PATH/HOME/TMPDIR/DATABASE_URL survive; GBRAIN_HOME kept for the existing HOME isolation). Adapts GStack's buildHermeticEnv to gbrain's shell runner. Verified: a 78-test e2e file passes with DATABASE_URL surviving + a planted GBRAIN_BRAIN_ID scrubbed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.50.0 ci: reliability hardening — cancel-superseded + per-job timeouts + actionlint + hermetic E2E env Ports GStack's CI-reliability hygiene to gbrain's hot-path workflows: concurrency cancel-in-progress (PR-number keyed), per-job timeout-minutes (no more 6-hour zombie jobs), an actionlint workflow, and an operator-env scrub in run-e2e.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
9.6 KiB
Bash
Executable File
244 lines
9.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Run E2E tests ONE FILE AT A TIME.
|
|
#
|
|
# Bun's default is to run test files in parallel (each in its own worker).
|
|
# Our E2E suite shares one Postgres database across all 13 files, and
|
|
# `setupDB()` does TRUNCATE CASCADE + fixture import. When files run in
|
|
# parallel, file A's TRUNCATE can race with file B's fixture import,
|
|
# producing observed fails like "expected 16 pages, got 8", missing
|
|
# links, orphaned timeline entries, etc. The flakiness was visible on
|
|
# ~3 of every 5 runs pre-fix.
|
|
#
|
|
# Running files sequentially eliminates the race entirely. It also costs
|
|
# some startup overhead (each file spins up a fresh bun process) but for
|
|
# a suite this size that is measured in ~1-2s per file, amortized under
|
|
# the natural per-file test time of 5-10s.
|
|
#
|
|
# Exits non-zero on the first failing file so CI fails fast.
|
|
#
|
|
# `--timeout=60000` matches the unit test suite. Bun's default is 5s,
|
|
# which is too tight for setupDB's TRUNCATE CASCADE on ~30 tables on
|
|
# CI runners under load (one CI flake observed on PR #475 hitting
|
|
# exactly 5000.09ms in the Tags beforeAll).
|
|
#
|
|
# HOME isolation: E2E tests call paths that resolve to gbrain init / saveConfig
|
|
# (e.g. setupDB writing config for the test container) and would otherwise
|
|
# write the user's real ~/.gbrain/config.json. The wrapper redirects HOME and
|
|
# GBRAIN_HOME to a tmpdir before bun starts so config writes land in the
|
|
# tmpdir, then verifies the user's real config md5 didn't change after the run.
|
|
# Both env vars are required: loadConfig/saveConfig resolve via HOME, while
|
|
# configPath/getDbUrlSource honor GBRAIN_HOME; setting only one leaves the
|
|
# other path escaping isolation. HOME is set before bun starts because Bun's
|
|
# os.homedir() caches at first call and in-process mutation would not take.
|
|
# Trap cleans up the tmpdir even on test failure.
|
|
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
# --- HOME isolation: snapshot real user config before switching ---
|
|
# Tolerate unset HOME (minimal containers, exotic CI shells) without tripping set -u.
|
|
REAL_HOME="${HOME:-/tmp}"
|
|
USER_CONFIG="$REAL_HOME/.gbrain/config.json"
|
|
USER_CONFIG_EXISTED=0
|
|
USER_CONFIG_MD5=""
|
|
# `{ ... } || true` swallows non-zero exit when the file is missing or md5 isn't
|
|
# installed, so set -e never aborts before the post-run breach detector can run.
|
|
md5_of() {
|
|
{ if command -v md5 >/dev/null 2>&1; then
|
|
md5 -q "$1" 2>/dev/null
|
|
elif command -v md5sum >/dev/null 2>&1; then
|
|
md5sum "$1" 2>/dev/null | awk '{print $1}'
|
|
fi
|
|
} || true
|
|
}
|
|
if [ -f "$USER_CONFIG" ]; then
|
|
USER_CONFIG_EXISTED=1
|
|
USER_CONFIG_MD5=$(md5_of "$USER_CONFIG")
|
|
fi
|
|
|
|
# Portable mktemp: explicit XXXXXX is required by GNU mktemp on Linux CI.
|
|
# `-t prefix` works on BSD but errors on GNU when the template lacks Xs.
|
|
E2E_TMP_HOME=$(mktemp -d "${TMPDIR:-/tmp}/gbrain-e2e.XXXXXX")
|
|
trap 'rm -rf "$E2E_TMP_HOME"' EXIT
|
|
|
|
export HOME="$E2E_TMP_HOME"
|
|
export GBRAIN_HOME="$E2E_TMP_HOME"
|
|
mkdir -p "$E2E_TMP_HOME/.gbrain"
|
|
|
|
# --- Hermetic env scrub: operator/agent context must not bleed into E2E ---
|
|
# A dev shell or a Conductor workspace exports CONDUCTOR_*, MCP_*, OPENCLAW_*,
|
|
# and GBRAIN_* config overrides (e.g. a stray GBRAIN_BRAIN_ID, GBRAIN_SOURCE,
|
|
# GBRAIN_*_THRESHOLD, GBRAIN_SUPERVISOR_PID_FILE) that would silently change
|
|
# test behavior — making "hermetic" E2E non-hermetic and its failures
|
|
# unreproducible across machines. Drop them before bun starts. This is a
|
|
# DENYLIST of operator-context prefixes (not an allowlist rebuild), so PATH,
|
|
# HOME, TMPDIR, CI, DATABASE_URL, and bun internals survive untouched. We keep
|
|
# GBRAIN_HOME (just set above for HOME isolation); everything else GBRAIN_* is
|
|
# an operator override the suite must not inherit. Adapts GStack's
|
|
# buildHermeticEnv() allowlist to gbrain's shell E2E runner.
|
|
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
|
case "$_e2e_var" in
|
|
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
|
|
*) unset "$_e2e_var" || true ;;
|
|
esac
|
|
done
|
|
|
|
# --dry-run-list: print the resolved file list (one per line) and exit. Used
|
|
# by scripts/ci-local.sh to smoke-test the argv branching at startup.
|
|
DRY_RUN_LIST=0
|
|
if [ "${1:-}" = "--dry-run-list" ]; then
|
|
DRY_RUN_LIST=1
|
|
shift
|
|
fi
|
|
|
|
# Argv-driven file list (used by `ci:local:diff`); fall back to the full glob.
|
|
if [ "$#" -gt 0 ]; then
|
|
files=("$@")
|
|
else
|
|
files=(test/e2e/*.test.ts)
|
|
fi
|
|
|
|
# SHARD env (e.g. SHARD=1/4) keeps every M-th file starting at index N (1-indexed).
|
|
# Used by scripts/ci-local.sh to fan 4 shards in parallel against 4 postgres
|
|
# containers. Sequential execution within a shard is preserved (the TRUNCATE
|
|
# CASCADE no-race rationale at the top of this file still holds).
|
|
if [ -n "${SHARD:-}" ]; then
|
|
shard_n=${SHARD%/*}
|
|
shard_m=${SHARD#*/}
|
|
if ! printf '%s' "$shard_n" | grep -qE '^[0-9]+$' || \
|
|
! printf '%s' "$shard_m" | grep -qE '^[0-9]+$' || \
|
|
[ "$shard_n" -lt 1 ] || [ "$shard_m" -lt 1 ] || [ "$shard_n" -gt "$shard_m" ]; then
|
|
echo "ERROR: invalid SHARD=$SHARD (expected N/M with 1<=N<=M, both integers)" >&2
|
|
exit 1
|
|
fi
|
|
filtered=()
|
|
i=0
|
|
for f in "${files[@]}"; do
|
|
if [ $((i % shard_m + 1)) -eq "$shard_n" ]; then
|
|
filtered+=("$f")
|
|
fi
|
|
i=$((i + 1))
|
|
done
|
|
# ${filtered[@]:-} avoids "unbound variable" under `set -u` when no files matched.
|
|
files=("${filtered[@]:-}")
|
|
# If the empty placeholder slipped in, drop it.
|
|
if [ "${#files[@]}" -eq 1 ] && [ -z "${files[0]}" ]; then
|
|
files=()
|
|
fi
|
|
fi
|
|
|
|
if [ "$DRY_RUN_LIST" = "1" ]; then
|
|
if [ "${#files[@]}" -eq 0 ]; then
|
|
exit 0
|
|
fi
|
|
printf '%s\n' "${files[@]}"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "${#files[@]}" -eq 0 ]; then
|
|
# Empty shard (e.g. SHARD=4/4 with only 3 files): nothing to do.
|
|
echo "No files for shard ${SHARD:-(unsharded)}; exiting clean."
|
|
exit 0
|
|
fi
|
|
|
|
pass_files=0
|
|
fail_files=0
|
|
fail_list=()
|
|
total_pass=0
|
|
total_fail=0
|
|
|
|
for f in "${files[@]}"; do
|
|
name=$(basename "$f")
|
|
echo ""
|
|
echo "=== $name ==="
|
|
# Cross-file isolation: terminate any stale connections from the prior
|
|
# file's pool before the next file's setupDB() runs. Without this,
|
|
# idle postgres connections from the previous bun process race with
|
|
# the next file's TRUNCATE CASCADE → cross-file fixture-state pollution
|
|
# (people/sarah-chen disappears mid-test, etc.). The terminate call is
|
|
# idempotent + fast (~50ms); on the first iteration there's nothing to
|
|
# terminate so it's effectively free.
|
|
if [ -n "${DATABASE_URL:-}" ]; then
|
|
psql "$DATABASE_URL" -At -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid != pg_backend_pid() AND datname = current_database()" >/dev/null 2>&1 || true
|
|
fi
|
|
# Hard outer timeout (180s per file). bun's --timeout is per-test; if a
|
|
# PGLite WASM call hangs in beforeAll/afterAll, --timeout never fires and
|
|
# the file wedges indefinitely. gtimeout/timeout SIGKILLs the file so the
|
|
# suite advances. gtimeout (macOS via coreutils) preferred; timeout (Linux)
|
|
# fallback; bare bun (no outer cap) if neither is installed.
|
|
if command -v gtimeout >/dev/null 2>&1; then
|
|
TIMEOUT_CMD="gtimeout 180"
|
|
elif command -v timeout >/dev/null 2>&1; then
|
|
TIMEOUT_CMD="timeout 180"
|
|
else
|
|
TIMEOUT_CMD=""
|
|
fi
|
|
if output=$($TIMEOUT_CMD bun test --timeout=60000 "$f" 2>&1); then
|
|
pass_files=$((pass_files + 1))
|
|
# Extract pass/fail counts from bun's summary (e.g., "123 pass")
|
|
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
|
|
total_pass=$((total_pass + p))
|
|
echo "$output" | tail -8
|
|
else
|
|
fail_files=$((fail_files + 1))
|
|
fail_list+=("$name")
|
|
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
|
|
fl=$(echo "$output" | grep -oE '[0-9]+ fail' | tail -1 | grep -oE '[0-9]+' || echo 0)
|
|
total_pass=$((total_pass + p))
|
|
total_fail=$((total_fail + fl))
|
|
echo "$output"
|
|
echo ""
|
|
echo "FAILED: $name"
|
|
# Continue so we see all failures; exit nonzero at the end.
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "========================================"
|
|
echo "E2E SUMMARY (sequential execution)"
|
|
echo "========================================"
|
|
echo "Files: $((pass_files + fail_files)) total, $pass_files passed, $fail_files failed"
|
|
echo "Tests: $total_pass passed, $total_fail failed"
|
|
|
|
# --- HOME isolation verification: fail loud on any out-of-isolation write ---
|
|
# Runs regardless of test pass/fail; isolation breach is higher-severity than
|
|
# any individual test failure. Exit 2 distinguishes from exit 1 (test failure).
|
|
# Three breach modes covered:
|
|
# 1. Config existed before AND was modified (md5 changed)
|
|
# 2. Config existed before AND was deleted during the run
|
|
# 3. Config did NOT exist before but was created during the run
|
|
AFTER_EXISTS=0
|
|
[ -f "$USER_CONFIG" ] && AFTER_EXISTS=1
|
|
AFTER_MD5=""
|
|
if [ "$AFTER_EXISTS" = "1" ]; then
|
|
AFTER_MD5=$(md5_of "$USER_CONFIG")
|
|
fi
|
|
BREACH_REASON=""
|
|
if [ "$USER_CONFIG_EXISTED" = "1" ] && [ "$AFTER_EXISTS" = "0" ]; then
|
|
BREACH_REASON="config existed before run but was deleted"
|
|
elif [ "$USER_CONFIG_EXISTED" = "0" ] && [ "$AFTER_EXISTS" = "1" ]; then
|
|
BREACH_REASON="config did not exist before run but was created"
|
|
elif [ -n "$USER_CONFIG_MD5" ] && [ "$AFTER_MD5" != "$USER_CONFIG_MD5" ]; then
|
|
BREACH_REASON="config md5 changed during run"
|
|
fi
|
|
if [ -n "$BREACH_REASON" ]; then
|
|
echo "" >&2
|
|
echo "ERROR: HOME isolation breach detected." >&2
|
|
echo " Reason: $BREACH_REASON" >&2
|
|
echo " Path: $USER_CONFIG" >&2
|
|
echo " Before: existed=$USER_CONFIG_EXISTED md5=${USER_CONFIG_MD5:-none}" >&2
|
|
echo " After: existed=$AFTER_EXISTS md5=${AFTER_MD5:-none}" >&2
|
|
echo " A test wrote outside the tmpdir HOME despite the override." >&2
|
|
exit 2
|
|
fi
|
|
|
|
if [ ${#fail_list[@]} -gt 0 ]; then
|
|
echo ""
|
|
echo "Failing files:"
|
|
for f in "${fail_list[@]}"; do
|
|
echo " - $f"
|
|
done
|
|
exit 1
|
|
fi
|