From 4a93097c71dc5650c455ff6e23768c37917e87c5 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 26 Oct 2026 06:21:42 -0400 Subject: [PATCH] fix(ci): golden-path report from an EXIT trap, live-safety fixture expects the overlay fragment path (D-1482) golden-path.sh now writes its JSON report from an EXIT trap when it ends non-zero with no failed step (missing prerequisite, signal, unbound variable), marking the first unfinished step failed with the reason. live-safety-fixture.sh still looked for @plugins//docker-compose.plugin.yml in the build plan; since D-0318 the rewritten fragment is the project overlay .nself/plugins//docker-compose.plugin.yml. This was the failing assertion behind the red Cascade integration step in the scheduled run. --- .github/workflows/ci.yml | 7 +++ scripts/ci/golden-path_test.sh | 86 +++++++++++++++++++++++++++++++ scripts/ci/live-safety-fixture.sh | 6 ++- scripts/golden-path.sh | 48 ++++++++++++++++- 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/golden-path_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12eb273c2..a8acc18c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,6 +214,13 @@ jobs: bash scripts/ci/panic-ratchet_test.sh bash scripts/ci/panic-ratchet.sh + # D-1482: scripts/golden-path.sh writes its report from an EXIT trap, so a + # run that aborts outside a step's own failure path still names the step. + # Stubbed nself/docker, offline. + - name: Golden path report controls + if: matrix.os == 'ubuntu-latest' + run: bash scripts/ci/golden-path_test.sh + # P7-REG-10: the machine-contract gate. Linux legs only (Windows has no # POSIX shell here and macOS legs add nothing the Linux proof lacks). # jq and make ship on the GitHub-hosted ubuntu images. diff --git a/scripts/ci/golden-path_test.sh b/scripts/ci/golden-path_test.sh new file mode 100644 index 000000000..7152e33cd --- /dev/null +++ b/scripts/ci/golden-path_test.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Purpose: prove scripts/golden-path.sh writes its JSON report however it ends, +# so a run that aborts outside the per-step failure paths still names +# the step (D-1482). Three cases against stub nself and docker +# binaries: a missing prerequisite, a signal while a step is in flight, +# and a health wait that times out (the deliberate path). +# Usage: bash scripts/ci/golden-path_test.sh +# Inputs: none; needs bash >= 4 (golden-path.sh uses associative arrays) and +# python3. Prints "skip" and exits 0 when no bash >= 4 is found. +# Outputs: "ok " per passing case; exit 0 when all pass, exit 1 on the +# first mismatch. +# Constraints: offline; no real nself, docker or network is touched (stubs lead +# PATH, the report, work dir and diagnostics paths are per-run). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="${HERE}/../golden-path.sh" +BASH4="" +for b in "$(command -v bash)" /opt/homebrew/bin/bash /usr/local/bin/bash; do + if [ -x "${b}" ] && [ "$("${b}" -c 'echo ${BASH_VERSINFO[0]}')" -ge 4 ]; then BASH4="${b}"; break; fi +done +[ -n "${BASH4}" ] || { echo "skip: no bash >= 4 on this host"; exit 0; } + +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT +mkdir -p "${WORK}/stubs" +# doctor fails fast so the health wait keeps looping; everything else succeeds. +cat > "${WORK}/stubs/nself" <<'STUB' +#!/bin/sh +case "$1" in doctor) exit 1 ;; *) exit 0 ;; esac +STUB +printf '#!/bin/sh\nexit 0\n' > "${WORK}/stubs/docker" +chmod +x "${WORK}/stubs/nself" "${WORK}/stubs/docker" + +# A dry run skips step 2's mkdir, so give the health wait a project dir to enter. +mkdir -p "${WORK}/w2/testproject" "${WORK}/w3/testproject" + +# report FILE EXPR: evaluate EXPR over the report JSON "d". +report() { python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));print(eval(sys.argv[2]))' "$1" "$2"; } +die() { echo "FAIL $1" >&2; exit 1; } + +# 1. Missing prerequisite: exits before any step, still reports. +R1="${WORK}/r1.json" +rc=0 +env -u NSELF_PLUGIN_LICENSE_KEY_OWNER PATH="${WORK}/stubs:${PATH}" GOLDEN_PATH_REPORT_FILE="${R1}" \ + "${BASH4}" "${SCRIPT}" > "${WORK}/o1" 2>&1 || rc=$? +[ "${rc}" -eq 1 ] || die "missing-prerequisite: exit ${rc}, want 1" +[ -f "${R1}" ] || die "missing-prerequisite: no report written" +[ "$(report "${R1}" 'd["overall"]')" = fail ] || die "missing-prerequisite: overall is not fail" +[ "$(report "${R1}" 'd["steps"]["1"]["status"]')" = fail ] || die "missing-prerequisite: step 1 not failed" +report "${R1}" 'd["steps"]["1"]["note"]' | grep -q NSELF_PLUGIN_LICENSE_KEY_OWNER || die "missing-prerequisite: note lacks the reason" +echo "ok missing-prerequisite" + +# 2. A signal while the health wait (step 6, outside run_step) is in flight. +R2="${WORK}/r2.json" +NSELF_PLUGIN_LICENSE_KEY_OWNER=dummy GOLDEN_PATH_DRY_RUN=1 GOLDEN_PATH_HEALTH_TIMEOUT=6 \ + GOLDEN_PATH_REPORT_FILE="${R2}" GOLDEN_PATH_WORK_DIR="${WORK}/w2" PATH="${WORK}/stubs:${PATH}" \ + "${BASH4}" "${SCRIPT}" > "${WORK}/o2" 2>&1 & +PID=$! +for _ in $(seq 1 50); do grep -q "Waiting for services healthy" "${WORK}/o2" 2>/dev/null && break; sleep 0.2; done +grep -q "Waiting for services healthy" "${WORK}/o2" || { kill "${PID}" 2>/dev/null || true; die "signal: never reached the health wait"; } +# bash runs the TERM trap once the health-wait subshell returns (the 6s timeout +# above), before the step's own failure handling would write the report. +kill -TERM "${PID}" +rc=0 +wait "${PID}" || rc=$? +[ "${rc}" -eq 143 ] || die "signal: exit ${rc}, want 143" +[ -f "${R2}" ] || die "signal: no report written" +[ "$(report "${R2}" 'd["overall"]')" = fail ] || die "signal: overall is not fail" +[ "$(report "${R2}" 'd["steps"]["6"]["status"]')" = fail ] || die "signal: step 6 not named as failed" +report "${R2}" 'd["steps"]["6"]["note"]' | grep -q "exited 143" || die "signal: note lacks the exit status" +echo "ok signal-during-step-6" + +# 3. Deliberate failure (health never arrives): the explicit path still reports once. +R3="${WORK}/r3.json" +rc=0 +NSELF_PLUGIN_LICENSE_KEY_OWNER=dummy GOLDEN_PATH_DRY_RUN=1 GOLDEN_PATH_HEALTH_TIMEOUT=1 \ + GOLDEN_PATH_REPORT_FILE="${R3}" GOLDEN_PATH_WORK_DIR="${WORK}/w3" PATH="${WORK}/stubs:${PATH}" \ + "${BASH4}" "${SCRIPT}" > "${WORK}/o3" 2>&1 || rc=$? +[ "${rc}" -eq 1 ] || die "health-timeout: exit ${rc}, want 1" +[ "$(report "${R3}" 'd["steps"]["6"]["note"]')" = "services never became healthy" ] || die "health-timeout: note was overwritten" +[ "$(report "${R3}" 'd["fail"]')" = 1 ] || die "health-timeout: fail count is not 1" +echo "ok health-timeout-keeps-its-note" + +# capture_diagnostics writes a fixed /tmp path; remove what case 3 left. +rm -f /tmp/golden-path-diag-step6.txt diff --git a/scripts/ci/live-safety-fixture.sh b/scripts/ci/live-safety-fixture.sh index 637cc92a7..bba8b5135 100755 --- a/scripts/ci/live-safety-fixture.sh +++ b/scripts/ci/live-safety-fixture.sh @@ -215,10 +215,12 @@ run_nself fresh 1 build --yes [ "$RC" = 0 ] && [ -f "$WORK/fresh/project/.env.secrets" ] || fail "the first build did not persist secrets" ok "first build: planned .env.secrets, --plan-id refused (E451), --yes applies" -# A plugin compose fragment edited after the plan is E450. +# A plugin compose fragment edited after the plan is E450. The build writes the +# rewritten fragment to the project overlay (.nself/plugins//, D-0318), so +# that is the plan artifact; the shared fragment under the plugin dir is an input. mkproj plug dev-plugin run_nself plug 1 build --plan --json -[ "$(jget '"@plugins/nself-alpha/docker-compose.plugin.yml" in [a["path"] for a in d["artifacts"]]')" = True ] || fail "the rewritten plugin fragment is not a plan artifact" +[ "$(jget '".nself/plugins/nself-alpha/docker-compose.plugin.yml" in [a["path"] for a in d["artifacts"]]')" = True ] || fail "the rewritten plugin fragment is not a plan artifact" PID="$(jget 'd["plan_id"]')" sed -i.bak 's/"3901:3901"/"0.0.0.0:22:3901"/' "$WORK/plug/plugins/nself-alpha/docker-compose.plugin.yml" && rm -f "$WORK/plug/plugins/nself-alpha/docker-compose.plugin.yml.bak" P0="$(tree plug)" diff --git a/scripts/golden-path.sh b/scripts/golden-path.sh index 92df35e0c..adc19428c 100755 --- a/scripts/golden-path.sh +++ b/scripts/golden-path.sh @@ -35,7 +35,9 @@ # # Output: # Human-readable step-by-step log to stdout -# JSON report written to /tmp/golden-path-report.json +# JSON report written to /tmp/golden-path-report.json, however the script ends: +# an abort outside a step (missing prerequisite, signal) is recorded against the +# first step that had not finished (see on_exit) # # Exit codes: # 0 all 13 steps passed within timing budgets @@ -112,6 +114,10 @@ declare -A STEP_STATUS=() declare -A STEP_DURATION=() declare -A STEP_NOTE=() WORK_DIR="" +# Set to the step number while run_step executes it, and to a reason when the +# script aborts outside any step; the EXIT trap reads both (see on_exit). +CURRENT_STEP="" +ABORT_NOTE="" # ── Helpers ─────────────────────────────────────────────────────────────────── log() { printf "${CYAN_C}[golden-path]${RESET_C} %s\n" "$*"; } @@ -268,6 +274,7 @@ run_step() { fail_limit=$(awk "BEGIN { printf \"%.0f\", ${baseline} * ${FAIL_MULT} }") log "Step ${step_num}/13: ${step_label}" + CURRENT_STEP="${step_num}" local t_start t_start=$(date +%s) @@ -279,10 +286,12 @@ run_step() { STEP_DURATION[$step_num]=0 STEP_NOTE[$step_num]="dry-run" PASS=$((PASS + 1)) + CURRENT_STEP="" return 0 fi "${cmd[@]}" 2>&1 || exit_code=$? + CURRENT_STEP="" local t_end t_end=$(date +%s) @@ -430,7 +439,41 @@ cleanup() { rm -rf "${WORK_DIR}" fi } -trap cleanup EXIT + +# on_exit: the report must exist however the script ends. Every deliberate +# failure path writes it before `exit 1`, but an abort outside those paths (a +# missing prerequisite, an unbound variable, a signal) left no report, so the +# workflow summary could only say "no report was produced". When the exit status +# is non-zero and no step has failed, the first step with no recorded status (or +# the step in flight) is marked failed with the reason, and the report is +# rewritten. A report that already names a failed step is left alone. +on_exit() { + local rc=$? + trap - EXIT + if [ "${rc}" -ne 0 ] && [ "${FAIL}" -eq 0 ]; then + local s="${CURRENT_STEP}" i + if [ -z "${s}" ]; then + for i in $(seq 1 13); do + [ -z "${STEP_STATUS[$i]:-}" ] && { s="${i}"; break; } + done + fi + [ -z "${s}" ] && s=13 + STEP_STATUS[$s]="fail" + STEP_NOTE[$s]="${ABORT_NOTE:-script exited ${rc} before this step finished}" + FAIL=$((FAIL + 1)) + write_report + elif [ ! -f "${REPORT_FILE}" ]; then + write_report + fi + cleanup + exit "${rc}" +} +# A signal exits through the EXIT trap, so a cancelled or timed-out job still +# leaves its report. +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +trap on_exit EXIT # ── Pre-flight ──────────────────────────────────────────────────────────────── log "nSelf golden-path E2E smoke" @@ -438,6 +481,7 @@ log "━━━━━━━━━━━━━━━━━━━━━━━━━ if [ -z "${NSELF_PLUGIN_LICENSE_KEY_OWNER:-}" ]; then err "NSELF_PLUGIN_LICENSE_KEY_OWNER is not set — required for steps 10-13" + ABORT_NOTE="NSELF_PLUGIN_LICENSE_KEY_OWNER is not set" exit 1 fi