feat(ci): two-branch CI — changed-files-only PR lane, full-suite release lane, release-branch cuts, cancellation watchdog (#4486)

This commit is contained in:
Steven Enamakel
2026-07-03 21:43:06 -07:00
committed by GitHub
parent c4701a9373
commit 5b15bbc34c
19 changed files with 996 additions and 315 deletions
+58
View File
@@ -11,6 +11,7 @@ OS_NAME="$(uname -s 2>/dev/null || echo unknown)"
CHILD_PID=""
RECEIVED_SIGNAL=""
CHILD_OWNS_PROCESS_GROUP=0
WATCHDOG_PID=""
is_windows_shell() {
case "$OS_NAME" in
@@ -82,10 +83,66 @@ forward_cancel() {
fi
}
# Signal traps alone cannot stop builds inside `container:` jobs: the runner
# delivers SIGINT/SIGTERM to the host-side `docker exec` client, which does
# NOT forward them into the container (actions/runner#1503). Observed on run
# 28692500745: cancelled jobs kept building for 23-28 minutes to natural
# completion. This watchdog polls the Actions API for the run's cancellation
# and delivers the TERM ourselves, from inside the container.
#
# Requires: GH_TOKEN or GITHUB_TOKEN in the environment with `actions: read`
# (workflows set `env: GH_TOKEN: ${{ github.token }}` at the top level).
# Silently disabled outside GitHub Actions or when no token is available.
start_cancel_watchdog() {
[ "${CI_CANCEL_WATCHDOG:-1}" = "1" ] || return 0
[ -n "${GITHUB_ACTIONS:-}" ] || return 0
[ -n "${GITHUB_RUN_ID:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] || return 0
local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
if [ -z "$token" ]; then
echo "[ci-cancel-aware] watchdog disabled: no GH_TOKEN/GITHUB_TOKEN in env" >&2
return 0
fi
if ! command -v curl >/dev/null 2>&1; then
echo "[ci-cancel-aware] watchdog disabled: curl not available" >&2
return 0
fi
local api="${GITHUB_API_URL:-https://api.github.com}"
local url="${api}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
local interval="${CI_CANCEL_POLL_SECONDS:-20}"
local self=$$
(
# The parent's `set -euo pipefail` is inherited; a poll where conclusion
# is still null makes grep exit 1 and must not kill the watchdog loop.
set +e +o pipefail
while :; do
sleep "$interval"
# Extract the run's top-level status/conclusion without jq.
run_json="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${token}" \
-H "Accept: application/vnd.github+json" \
"$url" 2>/dev/null | head -c 4096)" || continue
status="$(printf '%s' "$run_json" | tr -d ' \n' | grep -o '"status":"[a-z_]*"' | head -1 | cut -d'"' -f4)"
conclusion="$(printf '%s' "$run_json" | tr -d ' \n' | grep -o '"conclusion":"[a-z_]*"' | head -1 | cut -d'"' -f4)"
if [ "$status" = "cancelled" ] || [ "$status" = "completed" ] || [ "$conclusion" = "cancelled" ]; then
echo "[ci-cancel-aware] watchdog: run status=${status:-?} conclusion=${conclusion:-?} — cancelling build" >&2
kill -TERM "$self" 2>/dev/null || true
exit 0
fi
done
) &
WATCHDOG_PID=$!
echo "[ci-cancel-aware] cancellation watchdog polling every ${interval}s (run ${GITHUB_RUN_ID})" >&2
}
cleanup() {
local status=$?
trap - EXIT INT TERM HUP
if [ -n "$WATCHDOG_PID" ]; then
kill "$WATCHDOG_PID" 2>/dev/null || true
fi
if [ -n "$CHILD_PID" ] && kill -0 "$CHILD_PID" 2>/dev/null; then
terminate_tree_term "$CHILD_PID"
for _ in $(seq 1 10); do
@@ -129,6 +186,7 @@ trap 'forward_cancel HUP' HUP
trap cleanup EXIT
echo "[ci-cancel-aware] exec: $(printf '%q ' "$@")" >&2
start_cancel_watchdog
start_child "$@"
set +e
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# PR CI Rust core coverage lane — changed-files-only cargo-llvm-cov.
#
# Fast-lane policy (PRs targeting main): instead of the full ~13k-test
# instrumented suite, run only the unit tests for the modules the PR touched:
# - src/<a>/<b>/... .rs → libtest filter "<a>::<b>" (domain-level scope, so
# sibling-module tests like store_tests.rs / ops.rs still run)
# - tests/<name>.rs → that integration-test target only (--test <name>)
# Coverage from all scoped runs is merged (--no-report + report) into a single
# lcov file; the PR CI Gate's diff-cover step enforces >= 80% on changed lines.
#
# NOTE: this means changed lines must be covered by tests in their own domain
# (or a changed integration test) — coverage contributed by unrelated suites
# no longer counts on the fast lane. The full suite still runs on main→release
# PRs (Release CI).
#
# Inputs (env):
# FULL "true" → run the full suite (build-config / lib.rs / script
# changes, detected by paths-filter)
# CHANGED_FILES shell-quoted, space-separated repo-relative paths from
# dorny/paths-filter (list-files: shell)
# OUT lcov output path (default lcov-core.info)
#
# Falls back to the FULL suite whenever scoping is not clearly safe.
set -euo pipefail
FULL="${FULL:-false}"
CHANGED_FILES="${CHANGED_FILES:-}"
OUT="${OUT:-lcov-core.info}"
MAX_CHANGED_FILES="${MAX_CHANGED_FILES:-200}"
log() { echo "[ci][rust-cov-changed] $*"; }
llvm_cov() {
bash scripts/ci-cancel-aware.sh cargo llvm-cov "$@"
}
run_full() {
log "running FULL instrumented suite (reason: $1)"
llvm_cov --no-fail-fast -p openhuman --lcov --output-path "${OUT}"
exit 0
}
if [ "${FULL}" = "true" ]; then
run_full "build-config/workflow-level change detected by paths-filter"
fi
# Portable across bash 3.2 (macOS) and 5.x (CI containers): no declare -A,
# no mapfile, and no empty-array "${arr[@]}" expansion under set -u.
#
# CHANGED_FILES is the shell-quoted list from dorny/paths-filter
# (list-files: shell). Filenames are PR-controlled, so never eval it —
# xargs unquotes tokens as data without ever invoking a shell. If xargs
# can't parse it (e.g. hostile quoting), we get an empty list and fall
# back to the full suite.
declare -a files=()
while IFS= read -r f; do
[ -n "${f}" ] && files+=("${f}")
done < <(printf '%s\n' "${CHANGED_FILES}" | xargs -n1 printf '%s\n' 2>/dev/null || true)
log "received ${#files[@]} changed rust file(s)"
if [ "${#files[@]}" -eq 0 ]; then
run_full "empty changed-file list — scoping unsafe"
fi
if [ "${#files[@]}" -gt "${MAX_CHANGED_FILES}" ]; then
run_full "${#files[@]} changed files exceed MAX_CHANGED_FILES=${MAX_CHANGED_FILES}"
fi
lib_filters_raw=""
test_targets_raw=""
for f in "${files[@]}"; do
case "${f}" in
src/lib.rs | src/main.rs)
run_full "root module ${f} changed — whole-crate scope"
;;
src/bin/*)
# Standalone backfill binaries (slack-backfill, gmail-backfill-3d) have
# no unit tests; nothing to scope to.
log "ignoring standalone-binary file: ${f}"
;;
src/*.rs)
p="${f#src/}"
p="${p%.rs}"
IFS='/' read -r -a segs <<<"${p}"
n="${#segs[@]}"
if [ "${segs[n - 1]}" = "mod" ]; then
segs=("${segs[@]:0:n-1}")
n="${#segs[@]}"
fi
if [ "${n}" -ge 2 ]; then
key="${segs[0]}::${segs[1]}"
else
key="${segs[0]}"
fi
lib_filters_raw="${lib_filters_raw}${key}
"
log "${f} → libtest filter '${key}'"
;;
src/*/*)
# Non-.rs asset embedded in a domain (e.g. agent prompt markdown under
# src/openhuman/agent/prompts/) — scope to that domain's tests.
p="${f#src/}"
IFS='/' read -r -a segs <<<"${p}"
n="${#segs[@]}"
if [ "${n}" -ge 3 ]; then
key="${segs[0]}::${segs[1]}"
else
key="${segs[0]}"
fi
lib_filters_raw="${lib_filters_raw}${key}
"
log "${f} → libtest filter '${key}' (embedded asset)"
;;
tests/*.rs)
name="${f#tests/}"
name="${name%.rs}"
if [[ "${name}" == */* ]]; then
# Nested support module — can affect any integration target.
run_full "shared integration-test support file ${f} changed"
fi
test_targets_raw="${test_targets_raw}${name}
"
log "${f} → integration target '--test ${name}'"
;;
*)
run_full "unclassified rust-relevant file ${f} changed"
;;
esac
done
declare -a lib_filters=()
while IFS= read -r k; do
[ -n "${k}" ] && lib_filters+=("${k}")
done < <(printf '%s' "${lib_filters_raw}" | sort -u)
declare -a test_targets=()
while IFS= read -r k; do
[ -n "${k}" ] && test_targets+=("${k}")
done < <(printf '%s' "${test_targets_raw}" | sort -u)
if [ "${#lib_filters[@]}" -eq 0 ] && [ "${#test_targets[@]}" -eq 0 ]; then
run_full "no scoped test targets derivable from the change set"
fi
# Drop artifacts from previous coverage runs so merged profdata only reflects
# this run (build cache for dependencies is unaffected).
llvm_cov clean --workspace
if [ "${#lib_filters[@]}" -gt 0 ]; then
log "running scoped lib unit tests with filters: ${lib_filters[*]}"
# libtest ORs multiple positional filters — one run covers all domains.
llvm_cov --no-report --no-fail-fast -p openhuman --lib -- "${lib_filters[@]}"
fi
if [ "${#test_targets[@]}" -gt 0 ]; then
for t in "${test_targets[@]}"; do
log "running changed integration-test target: ${t}"
llvm_cov --no-report --no-fail-fast -p openhuman --test "${t}"
done
fi
log "merging coverage into ${OUT}"
llvm_cov report --lcov --output-path "${OUT}"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# PR CI frontend unit-test lane — changed-files-only Vitest.
#
# Fast-lane policy (PRs targeting main): run only the tests related to the
# files the PR actually changed, via `vitest related` (static import graph —
# reliable here because dynamic imports are banned in app/src). Coverage is
# still written to app/coverage/lcov.info; the PR CI Gate's diff-cover step
# then enforces >= 80% on changed lines. Untested changed files still appear
# in the lcov report at 0% (vitest coverage.include is explicit), so the gate
# cannot be dodged by having no related tests.
#
# Inputs (env):
# FULL "true" → run the entire suite with coverage (config-level
# change: lockfile, vitest/vite/ts config, test setup, etc.)
# CHANGED_FILES shell-quoted, space-separated repo-relative paths from
# dorny/paths-filter (list-files: shell)
#
# Falls back to the FULL suite whenever scoping is not clearly safe.
set -euo pipefail
FULL="${FULL:-false}"
CHANGED_FILES="${CHANGED_FILES:-}"
# Above this many changed source files a scoped run buys little and the
# argv/related-graph bookkeeping gets silly — just run everything.
MAX_RELATED_FILES="${MAX_RELATED_FILES:-200}"
log() { echo "[ci][vitest-changed] $*"; }
run_full() {
log "running FULL Vitest coverage suite (reason: $1)"
exec bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:coverage
}
if [ "${FULL}" = "true" ]; then
run_full "config/workflow-level change detected by paths-filter"
fi
# CHANGED_FILES is the shell-quoted list from dorny/paths-filter
# (list-files: shell). Filenames are PR-controlled, so never eval it —
# xargs unquotes tokens as data without ever invoking a shell. If xargs
# can't parse it (e.g. hostile quoting), we get an empty list and fall
# back to the full suite.
declare -a files=()
while IFS= read -r f; do
[ -n "${f}" ] && files+=("${f}")
done < <(printf '%s\n' "${CHANGED_FILES}" | xargs -n1 printf '%s\n' 2>/dev/null || true)
log "received ${#files[@]} changed frontend file(s)"
if [ "${#files[@]}" -eq 0 ]; then
run_full "empty changed-file list — scoping unsafe"
fi
declare -a related=()
for f in "${files[@]}"; do
case "${f}" in
app/src/*.ts | app/src/*.tsx) ;;
*)
log "ignoring non-source path: ${f}"
continue
;;
esac
if [ ! -f "${f}" ]; then
log "skipping deleted/renamed file: ${f}"
continue
fi
# vitest runs from app/ (config root) — strip the workspace prefix.
related+=("${f#app/}")
done
if [ "${#related[@]}" -eq 0 ]; then
run_full "no surviving changed .ts/.tsx files under app/src — scoping unsafe"
fi
if [ "${#related[@]}" -gt "${MAX_RELATED_FILES}" ]; then
run_full "${#related[@]} changed files exceed MAX_RELATED_FILES=${MAX_RELATED_FILES}"
fi
log "running 'vitest related' with coverage for ${#related[@]} file(s):"
printf '[ci][vitest-changed] %s\n' "${related[@]}"
cd app
exec bash ../scripts/ci-cancel-aware.sh pnpm exec vitest related --run --coverage --config test/vitest.config.ts "${related[@]}"