Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,12 @@ jobs:
if-no-files-found: warn

macos-stock-bash:
name: Stock macOS Bash snapshot compatibility
name: Stock macOS Bash runner compatibility
runs-on: macos-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Run snapshot consumers with stock Bash
- name: Run focused runner contract with stock Bash
shell: /bin/bash {0}
env:
PATH: /bin:/usr/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin
Expand All @@ -338,7 +338,6 @@ jobs:
*) echo "::error::expected stock macOS Bash 3.2.57, got $BASH_VERSION"; exit 1 ;;
esac
/bin/bash --version | head -1
command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; }

shell_inventory="$RUNNER_TEMP/fm-shell-inventory"
bin/fm-lint.sh --list-files > "$shell_inventory"
Expand All @@ -364,6 +363,14 @@ jobs:
exit 1
}

runner_test_output=$(bin/fm-test-run-stock-bash.sh)
printf '%s\n' "$runner_test_output"
runner_test_count=$(printf '%s\n' "$runner_test_output" | grep -c '^ok - ')
[ "$runner_test_count" -eq 27 ] || {
echo "::error::expected 27 focused runner contract tests, got $runner_test_count"
exit 1
}

invariants:
name: Repo invariants
runs-on: ubuntu-latest
Expand Down
25 changes: 25 additions & 0 deletions bin/fm-test-run-stock-bash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# fm-test-run-stock-bash.sh - run the focused runner contract under stock
# macOS Bash 3.2, with an explicit local skip when that interpreter is absent.
set -eu

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

if [ ! -x /bin/bash ]; then
printf 'skip: stock Bash 3.2 unavailable (evidence: /bin/bash is missing)\n'
exit 0
fi

version=$(/bin/bash -c 'printf "%s" "$BASH_VERSION"')
case "$version" in
3.2.*) ;;
*)
printf 'skip: stock Bash 3.2 unavailable (evidence: /bin/bash reports %s)\n' "$version"
exit 0
;;
esac

export FM_STOCK_BASH_VERSION="$version"
PATH=/bin:/usr/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin
export PATH
exec /bin/bash "$ROOT/tests/fm-test-run.test.sh" "$@"
219 changes: 212 additions & 7 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1503,8 +1503,174 @@ fi
RUN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run.XXXXXX")
RECORDS="$RUN_TMP/records.tsv"
FAMILIES_TSV="$RUN_TMP/families.tsv"
SERIAL_CHILD_PID=
SERIAL_TEE_PID=
: >"$RECORDS"
trap 'rm -rf "$RUN_TMP"' EXIT

# shellcheck disable=SC2329 # Invoked indirectly by the cleanup and worker signal traps.
terminate_and_reap_process_tree() {
local root=$1 idx=0 parent children child seen pid grace=0 running state
local -a process_tree_pids=()
if ! kill -STOP "$root" 2>/dev/null; then
wait "$root" 2>/dev/null || true
return
fi
process_tree_pids[0]=$root
while [ "$idx" -lt "${#process_tree_pids[@]}" ]; do
parent=${process_tree_pids[$idx]}
children=$(ps -eo pid=,ppid= 2>/dev/null | awk -v parent="$parent" '$2 == parent { print $1 }' || true)
while IFS= read -r child; do
[ -n "$child" ] || continue
seen=0
for pid in "${process_tree_pids[@]}"; do
if [ "$pid" = "$child" ]; then
seen=1
break
fi
done
if [ "$seen" -eq 0 ] && kill -STOP "$child" 2>/dev/null; then
process_tree_pids[${#process_tree_pids[@]}]=$child
fi
done <<<"$children"
idx=$((idx + 1))
done
for pid in "${process_tree_pids[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
for pid in "${process_tree_pids[@]}"; do
kill -CONT "$pid" 2>/dev/null || true
done
while [ "$grace" -lt 100 ]; do
running=0
for pid in "${process_tree_pids[@]}"; do
state=$(ps -o stat= -p "$pid" 2>/dev/null | awk 'NR == 1 { print $1 }' || true)
case "$state" in
""|Z*) ;;
*) running=1; break ;;
esac
done
[ "$running" -eq 1 ] || break
sleep 0.01
grace=$((grace + 1))
done
for pid in "${process_tree_pids[@]}"; do
kill -STOP "$pid" 2>/dev/null || true
done
idx=0
while [ "$idx" -lt "${#process_tree_pids[@]}" ]; do
parent=${process_tree_pids[$idx]}
children=$(ps -eo pid=,ppid= 2>/dev/null | awk -v parent="$parent" '$2 == parent { print $1 }' || true)
while IFS= read -r child; do
[ -n "$child" ] || continue
seen=0
for pid in "${process_tree_pids[@]}"; do
if [ "$pid" = "$child" ]; then
seen=1
break
fi
done
if [ "$seen" -eq 0 ] && kill -STOP "$child" 2>/dev/null; then
process_tree_pids[${#process_tree_pids[@]}]=$child
fi
done <<<"$children"
idx=$((idx + 1))
done
for pid in "${process_tree_pids[@]}"; do
state=$(ps -o stat= -p "$pid" 2>/dev/null | awk 'NR == 1 { print $1 }' || true)
case "$state" in
""|Z*) ;;
*) kill -KILL "$pid" 2>/dev/null || true ;;
esac
done
wait "$root" 2>/dev/null || true
grace=0
while [ "$grace" -lt 100 ]; do
running=0
for pid in "${process_tree_pids[@]}"; do
state=$(ps -o stat= -p "$pid" 2>/dev/null | awk 'NR == 1 { print $1 }' || true)
case "$state" in
""|Z*) ;;
*) running=1; break ;;
esac
done
[ "$running" -eq 1 ] || break
sleep 0.01
grace=$((grace + 1))
done
}

process_group_has_running_members() {
ps -eo pgid=,stat= 2>/dev/null \
| awk -v group="$1" '$1 == group && $2 !~ /^Z/ { found=1; exit } END { exit !found }'
}

terminate_and_reap_process_group() {
local group=$1 grace=0
if ! kill -STOP -- "-$group" 2>/dev/null; then
wait "$group" 2>/dev/null || true
return
fi
kill -TERM -- "-$group" 2>/dev/null || true
kill -CONT -- "-$group" 2>/dev/null || true
while [ "$grace" -lt 100 ] && process_group_has_running_members "$group"; do
sleep 0.01
grace=$((grace + 1))
done
kill -STOP -- "-$group" 2>/dev/null || true
kill -KILL -- "-$group" 2>/dev/null || true
wait "$group" 2>/dev/null || true
grace=0
while [ "$grace" -lt 100 ] && process_group_has_running_members "$group"; do
sleep 0.01
grace=$((grace + 1))
done
}

# shellcheck disable=SC2329 # Invoked indirectly by the EXIT and signal traps through cleanup_run.
terminate_and_reap_background_job() {
local pid=$1 group
group=$(ps -o pgid= -p "$pid" 2>/dev/null | awk 'NR == 1 { gsub(/[[:space:]]/, "", $1); print $1 }' || true)
if [ "$group" = "$pid" ]; then
terminate_and_reap_process_group "$group"
else
terminate_and_reap_process_tree "$pid"
fi
}

# shellcheck disable=SC2329 # Registered by the EXIT and signal traps below.
cleanup_run() {
local rc=$? pid serial_child_owned=0 cleanup_pids="$RUN_TMP/cleanup-pids"
trap - EXIT INT TERM HUP
jobs -p >"$cleanup_pids" 2>/dev/null || true
if [ -n "$SERIAL_CHILD_PID" ]; then
terminate_and_reap_process_group "$SERIAL_CHILD_PID"
serial_child_owned=1
fi
while IFS= read -r pid; do
[ -n "$pid" ] || continue
if [ "$pid" = "$SERIAL_CHILD_PID" ] || [ "$pid" = "$SERIAL_TEE_PID" ]; then
continue
fi
terminate_and_reap_background_job "$pid"
if [ -n "$SERIAL_TEE_PID" ]; then
serial_child_owned=1
fi
done <"$cleanup_pids"
if [ -n "$SERIAL_TEE_PID" ]; then
if [ "$serial_child_owned" -eq 1 ]; then
wait "$SERIAL_TEE_PID" 2>/dev/null || true
else
terminate_and_reap_process_tree "$SERIAL_TEE_PID"
fi
fi
rm -rf "$RUN_TMP"
exit "$rc"
}

trap cleanup_run EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP

RUN_STARTED_ISO=$(now_iso)
RUN_STARTED_MS=$(now_ms)
Expand Down Expand Up @@ -1623,7 +1789,7 @@ record_script_result() {

run_one_serial() {
local script=$1
local base family runtime requirement out begin_iso begin_ms end_ms end_iso duration rc
local base family runtime requirement out fifo begin_iso begin_ms end_ms end_iso duration rc child_pid monitor_mode=
base=$(basename "$script")
family=$(family_for_basename "$base")
runtime=$(runtime_gate_for_basename "$base")
Expand All @@ -1636,10 +1802,21 @@ run_one_serial() {
"$begin_iso" "$script" "$family" "$runtime" "$requirement"

set +e
# Stream live output while retaining a copy for gate-skip detection.
# PIPESTATUS[0] is the test script; tee's exit is ignored for aggregate.
bash "$script" 2>&1 | tee "$out"
rc=${PIPESTATUS[0]}
fifo="$RUN_TMP/serial.$TOTAL.fifo"
mkfifo "$fifo"
tee "$out" <"$fifo" &
SERIAL_TEE_PID=$!
case $- in *m*) monitor_mode=1 ;; esac
set -m
bash "$script" >"$fifo" 2>&1 &
SERIAL_CHILD_PID=$!
[ -n "$monitor_mode" ] || set +m
wait "$SERIAL_CHILD_PID"
rc=$?
wait "$SERIAL_TEE_PID" 2>/dev/null || true
SERIAL_CHILD_PID=
SERIAL_TEE_PID=
rm -f "$fifo"
set -e
: "${rc:=1}"

Expand Down Expand Up @@ -1677,6 +1854,7 @@ else
active_workers=$((active_workers - 1))
set +e
wait "$pid"
terminate_and_reap_process_group "$pid"
set -e
work="$RUN_TMP/w$idx"
rc=$(cat "$work/exit" 2>/dev/null || echo 1)
Expand Down Expand Up @@ -1738,16 +1916,42 @@ else
requirement=$(runtime_gate_requirement "$runtime")
printf 'FM_TEST_BEGIN %s %s family=%s runtime_gate=%s gate_requirement=%s\n' \
"$(now_iso)" "$script" "$family" "$runtime" "$requirement"
monitor_mode=
case $- in *m*) monitor_mode=1 ;; esac
set -m
(
set +m
set +e
child_pid=
# shellcheck disable=SC2329 # Registered by the worker signal traps below.
worker_signal_exit() {
local signal_rc=$1 pid signal_pids="$work/signal-pids"
trap - INT TERM HUP
if [ -n "$child_pid" ]; then
terminate_and_reap_process_tree "$child_pid"
else
jobs -p >"$signal_pids" 2>/dev/null || true
while IFS= read -r pid; do
[ -n "$pid" ] || continue
terminate_and_reap_process_tree "$pid"
done <"$signal_pids"
fi
exit "$signal_rc"
}
trap 'worker_signal_exit 130' INT
trap 'worker_signal_exit 143' TERM
trap 'worker_signal_exit 129' HUP
export TMPDIR="$work/tmp"
export TMP="$work/tmp"
unset FM_HOME FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_ROOT_OVERRIDE \
FM_PROJECTS_OVERRIDE FM_CONFIG_OVERRIDE FM_BACKEND 2>/dev/null || true
cd "$ROOT" || exit 1
begin_ms=$(now_ms)
bash "$script" >"$work/output" 2>&1
bash "$script" >"$work/output" 2>&1 &
child_pid=$!
wait "$child_pid"
rc=$?
trap - INT TERM HUP
end_ms=$(now_ms)
duration=$((end_ms - begin_ms))
if [ "$duration" -lt 0 ]; then
Expand All @@ -1758,6 +1962,7 @@ else
exit 0
) &
WORKER_PIDS[worker_n]=$!
[ -n "$monitor_mode" ] || set +m
WORKER_IDX[worker_n]=$worker_n
WORKER_SCRIPTS[worker_n]=$script
active_workers=$((active_workers + 1))
Expand Down
12 changes: 11 additions & 1 deletion docs/fm-test-portable-shards.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,17 @@ It separately verifies that the portable serial CI shards are non-empty, disjoin

Portable shards, each portable serial shard, and the Herdr lane upload runner-generated timing JSON.
`bin/fm-test-run.sh --aggregate-json` creates the combined summary artifact.
`.github/workflows/ci.yml` owns the exact artifact names and aggregation wiring.

## Stock macOS Bash compatibility

The CI job `macos-stock-bash` runs `tests/fm-fleet-snapshot-view.test.sh`, `tests/fm-bearings-snapshot.test.sh`, and the focused `tests/fm-test-run.test.sh` contract under `/bin/bash` from the macOS image.
The snapshot suites preserve stock-Bash runtime coverage for fleet snapshot/view and Bearings rendering, while the focused runner contract covers parallel scheduling, result aggregation, quoting, private temporary paths, signal cleanup, and the GNU/BSD `stat` difference.
The same job performs the repository-wide Bash parse sweep before the runtime test suites.
The compatibility wrapper pins `PATH` so nested runner invocations also resolve `bash` to `/bin/bash`, and the focused contract verifies that interpreter identity.
The focused runner compatibility boundary is stock macOS Bash 3.2.57 plus the commands explicitly required by its fixtures, including `awk`, `kill`, `mkfifo`, `mktemp`, `ps`, `sleep`, `stat`, `tee`, and `python3` for JSON assertions.
The focused runner contract's local reproduction command is `bin/fm-test-run-stock-bash.sh`.
When Bash 3.2 is unavailable locally, that wrapper may emit `skip: stock Bash 3.2 unavailable (evidence: <version or missing /bin/bash>)` and exit successfully; CI remains required on `macos-latest` and does not accept that skip.
`.github/workflows/ci.yml` owns the exact CI command sequence, expected test counts, artifact names, and aggregation wiring.

## Local entry points

Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees |
| `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane |
| `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, declarative runtime gates, proven-isolated `--jobs`, coverage guard, timing/JSON |
| `fm-test-run-stock-bash.sh` | Run the focused runner contract under stock macOS Bash 3.2, or emit explicit local skip evidence |
| `fm-test-isolation-proof.sh` | Concurrent isolation proof and proven-isolated candidate set owner |
| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section |
| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness |
Expand Down
Loading
Loading