mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
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:
@@ -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
|
||||
|
||||
Executable
+163
@@ -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}"
|
||||
Executable
+81
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user