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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions scripts/ci/golden-path_test.sh
Original file line number Diff line number Diff line change
@@ -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 <case>" 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
6 changes: 4 additions & 2 deletions scripts/ci/live-safety-fixture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/, 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)"
Expand Down
48 changes: 46 additions & 2 deletions scripts/golden-path.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" "$*"; }
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -430,14 +439,49 @@ 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"
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

Expand Down
Loading