Files
2026-07-13 12:58:18 +08:00

911 lines
47 KiB
Bash

#!/usr/bin/env bats
# Tests for promote-fleet.sh — the per-service promote loop extracted from
# .github/workflows/showcase_promote.yml.
#
# Core invariant under test (the bug this script fixes): a single red service
# must NOT abort the fleet. Every service in the CSV is attempted regardless of
# individual failures; the run aggregates a failed-set + succeeded-set, prints a
# summary, and exits non-zero iff ANY service failed.
#
# The real `bin/railway` is replaced on PATH-independent terms via RAILWAY_BIN,
# pointed at a stub that succeeds/fails per service name.
#
# NB on assertion gating: bats does NOT run test bodies under `set -e` (errexit).
# Only the FINAL command's exit status decides whether a test passes — a non-zero
# command on any earlier line does NOT abort the test. So a bare `[[ ... ]]` on a
# non-final line is a silent no-op: if it's false, nothing fails. That is why every
# substantive / non-final assertion MUST be written `[[ ... ]] || fail "message"`.
# The `|| fail` is what actually forces the hard failure (and supplies the
# diagnostic MESSAGE) when the check is violated — e.g. a service missing from
# $GITHUB_OUTPUT reports WHY it failed instead of being silently passed over.
# Dropping the `|| fail` from an intermediate assertion turns it into a false-green.
# fail <msg> — print the message to the bats failure stream and abort the test.
fail() {
echo "$1" >&2
return 1
}
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../promote-fleet.sh"
STUB_DIR="$BATS_TEST_TMPDIR/stub"
mkdir -p "$STUB_DIR"
# Capture the per-step GitHub Actions output to a temp file so the script's
# `$GITHUB_OUTPUT` append (succeeded_csv=...) runs exactly as it would in CI.
# Each test reads this file to assert the exported succeeded set.
export GITHUB_OUTPUT="$BATS_TEST_TMPDIR/github_output"
: > "$GITHUB_OUTPUT"
# Stub `railway`: invoked as `railway promote <svc> [flags...]`.
# Succeeds for A and C, fails (exit 7) for B. Echoes its service so we can
# assert every service was attempted (including C, AFTER B failed). Also
# asserts the FIRST positional arg is literally `promote` so an arg-order
# regression in the script (e.g. dropping the subcommand) is caught.
cat > "$STUB_DIR/railway" <<'STUB'
#!/usr/bin/env bash
# args: promote <svc> --yes --non-interactive [--digest REF]
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
case "$svc" in
svc-b) exit 7 ;; # chronically-red service (the abort trigger pre-fix)
*) exit 0 ;;
esac
STUB
chmod +x "$STUB_DIR/railway"
export RAILWAY_BIN="$STUB_DIR/railway"
# All-green stub for the success case.
cat > "$STUB_DIR/railway-green" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
echo "STUB called for: $2"
exit 0
STUB
chmod +x "$STUB_DIR/railway-green"
}
@test "attempts every service even after one fails, and exits non-zero" {
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
# (1) all three attempted — C runs even though B failed before it
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-b"* ]] || fail "svc-b not attempted: $output"
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted: $output"
# (2) non-zero aggregate exit because at least one service failed
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status"
# (3) summary classifies B as failed, A and C as succeeded. Anchor to the
# EXACT summary lines (not bare substrings) so a misclassification (e.g.
# svc-b leaking into the succeeded set) is actually caught.
[[ "$output" == *"FAILED (1): svc-b=7"* ]] || fail "wrong FAILED summary line: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "wrong SUCCEEDED summary line: $output"
# (4) the succeeded set is exported to $GITHUB_OUTPUT for verify-prod scoping.
# Only the services that actually promoted (a and c) — never the failed b.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-c"* ]] || fail "missing/wrong succeeded_csv: $output"
[[ "$output" != *"svc-b"* ]] || fail "failed svc-b leaked into GITHUB_OUTPUT: $output"
}
@test "all-green fleet exits zero" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-b"* ]] || fail "svc-b not attempted: $output"
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted: $output"
# All three exported as the succeeded set.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-b,svc-c"* ]] || fail "wrong succeeded_csv: $output"
}
@test "stray/trailing commas in CSV are skipped; real services still attempted" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,,svc-c" bash "$SCRIPT"
# Both non-empty services attempted; empty token between the commas ignored.
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "wrong SUCCEEDED summary line: $output"
# The "Attempted N" count must reflect services ACTUALLY attempted (2), not
# the raw CSV token count (3, including the empty token between the commas).
[[ "$output" == *"Attempted 2 service(s)"* ]] || fail "wrong Attempted count (should skip empty token): $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-c"* ]] || fail "wrong succeeded_csv: $output"
}
@test "single failing service still fails the run" {
run env SERVICES_CSV="svc-b" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status"
[[ "$output" == *"STUB called for: svc-b"* ]] || fail "svc-b not attempted: $output"
[[ "$output" == *"FAILED (1): svc-b=7"* ]] || fail "wrong FAILED summary line: $output"
}
@test "all-fail run still exports an EMPTY succeeded_csv to GITHUB_OUTPUT" {
# verify-prod's empty-guard depends on the key being present-but-empty on the
# all-fail-via-promote path (the loop ran, every service failed, zero
# promoted) — an absent key (vs an empty value) is a different contract and
# would break downstream scoping. NB this empty-but-present contract holds for
# the all-fail path only; the script's early-exit guards (empty CSV / missing
# RAILWAY_BIN / all-empty-token) exit BEFORE the $GITHUB_OUTPUT write, so no
# key is emitted there. Assert key present, value empty.
run env SERVICES_CSV="svc-b" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status: $output"
run grep '^succeeded_csv=' "$GITHUB_OUTPUT"
[ "$status" -eq 0 ] || fail "succeeded_csv key missing from GITHUB_OUTPUT"
# The value must be EMPTY: the line is exactly `succeeded_csv=` (no service).
[ "$output" = "succeeded_csv=" ] || fail "succeeded_csv should be empty on all-fail, got: $output"
}
@test "single succeeding service exits zero" {
run env SERVICES_CSV="svc-a" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
}
@test "passes --digest through to railway for a single service" {
# Stub that asserts --digest is forwarded AND the first arg is `promote`.
cat > "$STUB_DIR/railway-digest" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
echo "ARGS: $*"
[[ "$*" == *"--digest sha256:deadbeef"* ]] || { echo "missing digest" >&2; exit 9; }
exit 0
STUB
chmod +x "$STUB_DIR/railway-digest"
export RAILWAY_BIN="$STUB_DIR/railway-digest"
run env SERVICES_CSV="svc-a" DIGEST="sha256:deadbeef" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
# Anchor to the STUB's received-args marker (`ARGS: ...`), NOT the script's
# pre-invocation `==> ...` echo. The pre-invocation echo would pass even if
# the script dropped --digest before actually invoking railway; only the
# stub's ARGS line proves the flag was forwarded to the real invocation.
[[ "$output" == *"ARGS: "*"--digest sha256:deadbeef"* ]] || fail "digest not forwarded to railway invocation: $output"
}
@test "empty CSV fails loud rather than silently succeeding" {
run env SERVICES_CSV="" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on empty CSV, got $status"
}
@test "all-empty-token CSV fails loud rather than silently no-op succeeding" {
# A non-empty CSV that parses to ONLY empty tokens (e.g. ",,") must NOT exit 0
# claiming success — every token is skipped, zero services are attempted, and
# that is a false success the empty-string guard alone does not catch.
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV=",," bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on all-empty-token CSV, got $status: $output"
[[ "$output" != *"STUB called for:"* ]] || fail "no service should have been attempted: $output"
[[ "$output" == *"::error::"* ]] || fail "expected an ::error:: for zero-attempted CSV: $output"
}
@test "missing/non-executable RAILWAY_BIN fails loud before attempting any service" {
# A bad RAILWAY_BIN would otherwise make every iteration fail with 126/127 and
# misreport a single environment error as N per-service promote failures.
run env RAILWAY_BIN="$STUB_DIR/does-not-exist" SERVICES_CSV="svc-a,svc-c" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on missing RAILWAY_BIN, got $status: $output"
[[ "$output" == *"::error::"* ]] || fail "expected a distinct ::error:: naming the missing binary: $output"
[[ "$output" == *"does-not-exist"* ]] || fail "error should name the missing binary: $output"
# The guard fires BEFORE the loop: no per-service promote was attempted.
[[ "$output" != *"==> "* ]] || fail "no service promote should have been attempted: $output"
[[ "$output" != *"promote failed for"* ]] || fail "env error misattributed as per-service failure: $output"
}
@test "whitespace around tokens is trimmed; trimmed names are attempted" {
# `IFS=',' read` does not trim, so "svc-a, svc-c" yields a literal " svc-c"
# (leading space). The script must trim so the REAL service name is promoted.
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a, svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
# Trimmed: stub receives `svc-c`, NOT ` svc-c`.
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted (trimmed): $output"
[[ "$output" != *"STUB called for: svc-c"* ]] || fail "leading space not trimmed from token: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "wrong SUCCEEDED summary line: $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-c"* ]] || fail "wrong succeeded_csv (trimmed): $output"
}
@test "whitespace-only token is correctly skipped" {
# A token of only whitespace (the middle " " in "svc-a, ,svc-c") must be
# treated like an empty token and skipped — not promoted as a blank service.
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a, ,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "whitespace-only token not skipped: $output"
[[ "$output" == *"Attempted 2 service(s)"* ]] || fail "wrong Attempted count: $output"
}
@test "aggregates STAGING_DRIFT_MARKER lines into staging_drift output + summary" {
# Stub that emits a drift marker for svc-b (staging not serving :latest) while
# still SUCCEEDING — drift is a warning surface, not a gate. The script must
# scan each promote's stdout, aggregate the markers, write them to
# $GITHUB_OUTPUT (staging_drift=...) and surface them in the summary.
cat > "$STUB_DIR/railway-drift" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
if [ "$svc" = "svc-b" ]; then
echo "STAGING_DRIFT_MARKER: svc-b(running=f9454e79fbf5,latest=261ccdef3f9a)"
fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-drift"
export RAILWAY_BIN="$STUB_DIR/railway-drift"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
# Drift does not fail the run (every service succeeded).
[ "$status" -eq 0 ] || fail "drift must not fail the run, got $status: $output"
# Summary surfaces the drift loudly and names the running/latest digests.
[[ "$output" == *"STAGING DRIFT (1)"* ]] || fail "missing drift summary block: $output"
[[ "$output" == *"running=f9454e79fbf5"* ]] || fail "summary missing RUNNING digest: $output"
[[ "$output" == *"latest=261ccdef3f9a"* ]] || fail "summary missing :latest digest: $output"
# The aggregated payload is exported for the notify job's Slack message.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"staging_drift=svc-b(running=f9454e79fbf5,latest=261ccdef3f9a)"* ]] \
|| fail "missing/wrong staging_drift in GITHUB_OUTPUT: $output"
}
@test "joins multiple STAGING_DRIFT_MARKER entries with '; ' separator" {
# Two services drift. The aggregated staging_drift payload must join the
# entries with "; " (semicolon + space), NOT ";" — `${drift[*]}` under
# IFS='; ' would only honor the first IFS char and drop the space.
cat > "$STUB_DIR/railway-drift2" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
if [ "$svc" = "svc-a" ]; then
echo "STAGING_DRIFT_MARKER: svc-a(running=aaaaaaaaaaaa,latest=bbbbbbbbbbbb)"
fi
if [ "$svc" = "svc-c" ]; then
echo "STAGING_DRIFT_MARKER: svc-c(running=cccccccccccc,latest=dddddddddddd)"
fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-drift2"
export RAILWAY_BIN="$STUB_DIR/railway-drift2"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "drift must not fail the run, got $status: $output"
[[ "$output" == *"STAGING DRIFT (2)"* ]] || fail "missing drift summary block: $output"
run cat "$GITHUB_OUTPUT"
# Both entries present, joined with "; " (NOT ";" — the dropped-space bug).
[[ "$output" == *"staging_drift=svc-a(running=aaaaaaaaaaaa,latest=bbbbbbbbbbbb); svc-c(running=cccccccccccc,latest=dddddddddddd)"* ]] \
|| fail "multi-entry drift not joined with '; ' separator: $output"
}
@test "no drift markers -> empty staging_drift output, no drift summary" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" != *"STAGING DRIFT"* ]] || fail "no drift expected but summary shows one: $output"
run cat "$GITHUB_OUTPUT"
# Key present but empty (the non-drift contract): staging_drift=
[[ "$output" == *"staging_drift="* ]] || fail "staging_drift key absent: $output"
[[ "$output" != *"staging_drift=svc"* ]] || fail "unexpected drift payload: $output"
}
# ── results JSON for the three-variant Slack renderer ───────────────────────
#
# promote-fleet emits a base64-encoded `results` blob (schema_version=1)
# consumed by .github/workflows/showcase_promote_notify.yml. The renderer is
# the SSOT for the schema; these tests assert promote-fleet emits a blob that
# matches it: valid base64 → valid JSON, schema_version=1, BOTH succeeded[] and
# failed[] present, succeeded[] entries are `{service}` objects, and failed[]
# entries are `{service, exit, category}` with the real exit code. The
# end-to-end RED-GREEN proof (decode → pipe through the dry-run harness →
# rendered ⚠️ partial / ✅ success / ❌ total message) lives outside bats.
# decode_results_b64 — read results_b64 from $GITHUB_OUTPUT and decode it to
# stdout as JSON, failing the test loudly if the key is absent or the value is
# not valid base64/JSON. Mirrors the renderer's decode step.
decode_results_b64() {
local b64
b64=$(grep '^results_b64=' "$GITHUB_OUTPUT" | head -1 | cut -d= -f2-)
[ -n "$b64" ] || fail "results_b64 key absent from GITHUB_OUTPUT: $(cat "$GITHUB_OUTPUT")"
printf '%s' "$b64" | base64 -d 2>/dev/null || fail "results_b64 is not valid base64: $b64"
}
@test "emits results JSON (schema_version=1) with both succeeded[] and failed[] on a partial promote" {
# Mixed run: svc-a/svc-c succeed, svc-b fails (exit 7). The emitted blob must
# carry the partial outcome the renderer turns into the ⚠️ partial message.
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on partial, got $status: $output"
# Decode → valid JSON with schema_version=1.
json=$(decode_results_b64)
echo "$json" | jq -e '.schema_version == 1' >/dev/null \
|| fail "schema_version != 1: $json"
# succeeded[] = the two that pinned, as {service} objects (renderer reads
# length only, but we emit objects for symmetry/future use).
[ "$(echo "$json" | jq -r '.succeeded | length')" = "2" ] \
|| fail "succeeded[] should have 2 entries: $json"
echo "$json" | jq -e '[.succeeded[].service] | sort == ["svc-a","svc-c"]' >/dev/null \
|| fail "succeeded[] services wrong: $json"
# failed[] = svc-b with its REAL exit code (7) + the default category. The
# renderer renders "`svc-b`exit 7 (promote-failed)".
[ "$(echo "$json" | jq -r '.failed | length')" = "1" ] \
|| fail "failed[] should have exactly 1 entry: $json"
echo "$json" | jq -e '.failed[0] == {service:"svc-b", exit:7, category:"promote-failed"}' >/dev/null \
|| fail "failed[0] does not match {svc-b, exit 7, promote-failed}: $json"
# The failed service must NOT leak into succeeded[].
echo "$json" | jq -e '[.succeeded[].service] | index("svc-b") == null' >/dev/null \
|| fail "failed svc-b leaked into succeeded[]: $json"
}
@test "emits results JSON with empty failed[] on an all-green promote" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
json=$(decode_results_b64)
echo "$json" | jq -e '.schema_version == 1' >/dev/null || fail "schema_version != 1: $json"
[ "$(echo "$json" | jq -r '.succeeded | length')" = "3" ] || fail "succeeded[] should have 3: $json"
# Empty failed[] → the renderer's ✅ success variant.
echo "$json" | jq -e '.failed == []' >/dev/null || fail "failed[] should be empty on all-green: $json"
}
@test "emits results JSON with empty succeeded[] on an all-fail promote (total variant)" {
# Every service fails → succeeded[]=[], failed[] non-empty → the renderer's
# ❌ total-failure variant (succeeded==0 && failed>0 defensive branch).
run env SERVICES_CSV="svc-b" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status: $output"
json=$(decode_results_b64)
echo "$json" | jq -e '.schema_version == 1' >/dev/null || fail "schema_version != 1: $json"
echo "$json" | jq -e '.succeeded == []' >/dev/null || fail "succeeded[] should be empty on all-fail: $json"
[ "$(echo "$json" | jq -r '.failed | length')" = "1" ] || fail "failed[] should have 1: $json"
echo "$json" | jq -e '.failed[0].exit == 7' >/dev/null || fail "failed exit code wrong: $json"
}
# ── U4: tier-ordered closure promote with dependent-tier gating ─────────────
#
# When CLOSURE_PLAN (tier-annotated `tier:name,tier:name,...` from U3's
# resolve-promote-targets.sh) is supplied, promote-fleet iterates the closure
# BY TIER (0->1->2). The existing per-service best-effort loop is preserved
# WITHIN a tier, but a tier GATES its dependents: if ANY tier-0 service fails
# pin+verify, tiers 1 and 2 do NOT promote (a stale aimock/harness under fresh
# integrations = non-equivalent prod); a tier-1 failure blocks tier-2. Blocked
# tiers are reported NOT-ATTEMPTED (distinct from FAILED) so the operator can
# re-run (spec R-B). succeeded_csv = the actually-pinned closure subset.
# A per-tier stub: tier-0 = aimock(svc-a)/pocketbase(svc-b); tier-1 =
# harness(svc-h); tier-2 = integrations(svc-i1,svc-i2). svc-fail always fails.
setup_tier_stub() {
cat > "$STUB_DIR/railway-tier" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
case "$svc" in
svc-fail) exit 7 ;;
*) exit 0 ;;
esac
STUB
chmod +x "$STUB_DIR/railway-tier"
export RAILWAY_BIN="$STUB_DIR/railway-tier"
}
@test "U4: tier-0 failure HALTS tiers 1 and 2 (reported NOT-ATTEMPTED, not failed)" {
setup_tier_stub
# tier-0: svc-fail (fails) + svc-a (ok); tier-1: svc-h; tier-2: svc-i1,svc-i2.
run env CLOSURE_PLAN="0:svc-fail,0:svc-a,1:svc-h,2:svc-i1,2:svc-i2" bash "$SCRIPT"
# tier-0 members ARE attempted (best-effort within the failing tier).
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "tier-0 svc-fail not attempted: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "tier-0 svc-a not attempted: $output"
# tiers 1 and 2 are NOT attempted — the tier-0 failure gated them.
[[ "$output" != *"STUB called for: svc-h"* ]] || fail "tier-1 svc-h should NOT have been attempted after tier-0 failure: $output"
[[ "$output" != *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 should NOT have been attempted after tier-0 failure: $output"
[[ "$output" != *"STUB called for: svc-i2"* ]] || fail "tier-2 svc-i2 should NOT have been attempted after tier-0 failure: $output"
# Non-zero aggregate exit (a tier-0 service failed).
[ "$status" -ne 0 ] || fail "expected non-zero exit on tier-0 failure, got $status: $output"
# Blocked tiers reported as NOT-ATTEMPTED — distinct from FAILED so the
# operator knows they can re-run them once tier-0 is healthy.
[[ "$output" == *"NOT-ATTEMPTED"* ]] || fail "expected a NOT-ATTEMPTED report for gated tiers: $output"
[[ "$output" == *"svc-h"* ]] || fail "gated svc-h should be named in NOT-ATTEMPTED: $output"
[[ "$output" == *"svc-i1"* ]] || fail "gated svc-i1 should be named in NOT-ATTEMPTED: $output"
[[ "$output" == *"svc-i2"* ]] || fail "gated svc-i2 should be named in NOT-ATTEMPTED: $output"
# succeeded_csv = actually-pinned subset (only svc-a; NOT the gated tiers,
# NOT the failed svc-fail).
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a"* ]] || fail "succeeded_csv should be exactly the pinned subset (svc-a): $output"
[[ "$output" != *"svc-h"* ]] || fail "gated svc-h must not leak into succeeded_csv: $output"
[[ "$output" != *"svc-fail"* ]] || fail "failed svc-fail must not leak into succeeded_csv: $output"
}
@test "U4: tier-1 failure blocks tier-2 (tier-0 still promoted)" {
setup_tier_stub
# tier-0: svc-a (ok); tier-1: svc-fail (fails); tier-2: svc-i1.
run env CLOSURE_PLAN="0:svc-a,1:svc-fail,2:svc-i1" bash "$SCRIPT"
# tier-0 + tier-1 attempted.
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "tier-0 svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "tier-1 svc-fail not attempted: $output"
# tier-2 NOT attempted (gated by the tier-1 failure).
[[ "$output" != *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 should NOT have been attempted after tier-1 failure: $output"
[ "$status" -ne 0 ] || fail "expected non-zero exit on tier-1 failure, got $status: $output"
[[ "$output" == *"NOT-ATTEMPTED"* ]] || fail "expected NOT-ATTEMPTED report for gated tier-2: $output"
[[ "$output" == *"svc-i1"* ]] || fail "gated svc-i1 should be named in NOT-ATTEMPTED: $output"
# tier-0 svc-a DID pin even though tier-1 later failed.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a"* ]] || fail "tier-0 svc-a should be in succeeded_csv: $output"
}
@test "U4: all-green closure promotes every tier in order (0 before 1 before 2)" {
setup_tier_stub
run env CLOSURE_PLAN="0:svc-a,0:svc-b,1:svc-h,2:svc-i1,2:svc-i2" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit on all-green closure, got $status: $output"
# Every service attempted.
for s in svc-a svc-b svc-h svc-i1 svc-i2; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted: $output"
done
# Tier ordering: tier-0 services appear BEFORE tier-1, which appears BEFORE
# tier-2. Use the byte offset of each STUB line in $output to assert order.
a_pos="${output%%STUB called for: svc-a*}"
h_pos="${output%%STUB called for: svc-h*}"
i1_pos="${output%%STUB called for: svc-i1*}"
[ "${#a_pos}" -lt "${#h_pos}" ] || fail "tier-0 svc-a must promote BEFORE tier-1 svc-h: $output"
[ "${#h_pos}" -lt "${#i1_pos}" ] || fail "tier-1 svc-h must promote BEFORE tier-2 svc-i1: $output"
# All five exported as the succeeded set, in tier order.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-b,svc-h,svc-i1,svc-i2"* ]] || fail "wrong succeeded_csv: $output"
}
@test "U4: a tier-2 (leaf) failure does NOT gate anything; best-effort within the leaf tier" {
setup_tier_stub
# tier-0 ok, tier-1 ok, tier-2: svc-fail (fails) + svc-i1 (ok). The leaf tier
# has no dependents, so svc-i1 must STILL be attempted after svc-fail fails
# (the existing best-effort-within-a-tier behavior).
run env CLOSURE_PLAN="0:svc-a,1:svc-h,2:svc-fail,2:svc-i1" bash "$SCRIPT"
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "tier-2 svc-fail not attempted: $output"
[[ "$output" == *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 not attempted after a sibling failed (best-effort): $output"
[ "$status" -ne 0 ] || fail "expected non-zero exit (a service failed), got $status: $output"
run cat "$GITHUB_OUTPUT"
# svc-i1 pinned despite its sibling failing; svc-fail not in succeeded_csv.
[[ "$output" == *"succeeded_csv=svc-a,svc-h,svc-i1"* ]] || fail "wrong succeeded_csv (leaf best-effort): $output"
}
@test "U4: --digest override still works on the single-service closure path" {
# R-B: the single-service --digest escape path must survive the tier path.
cat > "$STUB_DIR/railway-tier-digest" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
echo "ARGS: $*"
[[ "$*" == *"--digest sha256:deadbeef"* ]] || { echo "missing digest" >&2; exit 9; }
exit 0
STUB
chmod +x "$STUB_DIR/railway-tier-digest"
export RAILWAY_BIN="$STUB_DIR/railway-tier-digest"
run env CLOSURE_PLAN="2:svc-a" DIGEST="sha256:deadbeef" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"ARGS: "*"--digest sha256:deadbeef"* ]] || fail "digest not forwarded on closure path: $output"
}
@test "U4: STAGING_DRIFT_MARKER aggregation works on the tier path too" {
cat > "$STUB_DIR/railway-tier-drift" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
if [ "$svc" = "svc-h" ]; then
echo "STAGING_DRIFT_MARKER: svc-h(running=f9454e79fbf5,latest=261ccdef3f9a)"
fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-tier-drift"
export RAILWAY_BIN="$STUB_DIR/railway-tier-drift"
run env CLOSURE_PLAN="0:svc-a,1:svc-h,2:svc-i1" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "drift must not fail the run, got $status: $output"
[[ "$output" == *"STAGING DRIFT (1)"* ]] || fail "missing drift summary block on tier path: $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"staging_drift=svc-h(running=f9454e79fbf5,latest=261ccdef3f9a)"* ]] \
|| fail "missing/wrong staging_drift on tier path: $output"
}
@test "U4: closure plan with empty/whitespace tokens within a tier skips them" {
setup_tier_stub
# A stray comma / whitespace token inside the tier-annotated plan must be
# skipped exactly like the flat-CSV path (preserve trim + empty-token skip).
run env CLOSURE_PLAN="0:svc-a,0: ,1:svc-h" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-h"* ]] || fail "svc-h not attempted: $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-h"* ]] || fail "wrong succeeded_csv (whitespace token skipped): $output"
}
# ── Within-tier parallel fan-out ────────────────────────────────────────────
#
# A full `service=all` promote is dominated by bin/railway's serial
# verify_serving_digest! (~300s/service). Running every promote in a tier
# strictly one-after-another overruns the job timeout mid-fleet. promote-fleet
# now backgrounds `promote_one` WITHIN a tier up to a bounded concurrency cap
# (PROMOTE_FANOUT, default 5), drains all PIDs at the end of the tier (the tier
# BARRIER), then reaps each service's result back into the aggregate. Cross-tier
# ordering + dependent-tier gating + best-effort + the final exit-nonzero-iff-any
# -failed semantics are all preserved.
#
# These tests exercise the REAL surface: bats shells out to the real
# promote-fleet.sh with a RAILWAY_BIN stub. The tier is encoded as the FIRST
# dash-separated field of the service name (e.g. `t0-a` is a tier-0 service) so
# the stub — which only receives `promote <svc>` — can record it without the
# script forwarding the tier.
#
# DETERMINISTIC CONCURRENCY PROOF (no wall-clock-sleep race).
# An earlier version slept a fixed 0.4s in each stub invocation and asserted the
# observed peak overlap was >= 2. That is timing-fragile: on a loaded CI host a
# backgrounded promote's spawn latency can approach the sleep, so the second
# invocation starts only AFTER the first already returned — peak reads 1 and the
# test FALSE-REDs "ran serially". (We were bitten by exactly this flake.)
#
# The rewrite proves parallelism by ACTUAL SIMULTANEITY via a RENDEZVOUS BARRIER,
# independent of any sleep-vs-spawn-latency race:
# * Each stub invocation atomically REGISTERS itself in a shared `running/`
# directory by creating a uniquely-named file (each writes only its OWN
# file — no shared-file append, so there is NO torn-write / atomicity risk).
# * It then BLOCKS, polling the live count of `running/` entries, until that
# count reaches BARRIER_TARGET (= min(cap, n_ready), the max simultaneity the
# launcher can actually reach) OR a generous timeout elapses.
# * While blocking it records the peak live count it ever observed to its own
# `peak.<svc>` file, then DEREGISTERS (removes its file) and exits.
# When the launcher CAN reach the cap, exactly BARRIER_TARGET invocations are
# provably alive together (they all release only once the target is met), so the
# recorded peak == BARRIER_TARGET DETERMINISTICALLY — no dependence on sleep.
# A genuinely-serial (broken) launcher never has >1 invocation alive, so the
# barrier is never met: each invocation blocks until the TIMEOUT, then exits one
# at a time and the recorded peak stays 1 → the `peak>=cap` assertion still goes
# RED on a serial run (RED-on-serial preserved), and the timeout guarantees the
# run TERMINATES rather than deadlocking.
#
# Tests that only need start/end ORDERING (the cross-tier barrier) still record a
# per-event start/end file (one file per event — again no shared-file append) and
# read epoch.ns timestamps back from those files.
# BARRIER_TIMEOUT_SECS — generous upper bound a stub invocation waits at the
# rendezvous before giving up (so a serial launcher terminates + REDs, never
# hangs). Comfortably longer than any real launch latency; short enough that a
# broken-serial run still finishes the bats test quickly.
BARRIER_TIMEOUT_SECS=10
# setup_timeline_stub <barrier_target> [fail_svc] — install a RAILWAY_BIN that,
# for each `promote <svc>`:
# 1. writes a per-event `start.<svc>` file (epoch.ns + tier) under $EVENTS_DIR,
# 2. registers itself in $RUNNING_DIR (atomic: creates its own unique file),
# 3. blocks at the rendezvous until $RUNNING_DIR holds <barrier_target> entries
# OR BARRIER_TIMEOUT_SECS elapses, recording the peak live count it saw to
# $PEAKS_DIR/peak.<svc>,
# 4. deregisters, writes a per-event `end.<svc>` file, then exits 0 (or exit 7
# if <svc> == <fail_svc>, AFTER recording everything so the failing svc is
# still observable).
# All recording is via one-file-per-event writes (no shared-file appends), so the
# measurement is race-free. Pass barrier_target = min(PROMOTE_FANOUT, n_ready).
setup_timeline_stub() {
local barrier_target="$1"
local fail_svc="${2:-}"
export EVENTS_DIR="$BATS_TEST_TMPDIR/events"
export RUNNING_DIR="$BATS_TEST_TMPDIR/running"
export PEAKS_DIR="$BATS_TEST_TMPDIR/peaks"
rm -rf "$EVENTS_DIR" "$RUNNING_DIR" "$PEAKS_DIR"
mkdir -p "$EVENTS_DIR" "$RUNNING_DIR" "$PEAKS_DIR"
cat > "$STUB_DIR/railway-timeline" <<STUB
#!/usr/bin/env bash
[ "\$1" = "promote" ] || { echo "expected first arg 'promote', got '\$1'" >&2; exit 99; }
svc="\$2"
tier="\${svc%%-*}" # leading t<N> field encodes the tier
echo "STUB called for: \$svc"
# (1) per-event start file (its OWN file — no shared append).
printf '%s %s\n' "\$(date +%s.%N)" "\$tier" > "$EVENTS_DIR/start.\$svc"
# (2) register at the rendezvous: create our own unique membership file.
printf '%s' "\$\$" > "$RUNNING_DIR/\$svc"
# (3) block until BARRIER_TARGET are simultaneously alive OR timeout; track the
# peak live count we observe. Count = number of membership files in
# \$RUNNING_DIR, computed by globbing into the positional params (no \`ls\`
# parsing — robust + shellcheck-clean) and reading \$#.
target=$barrier_target
deadline=\$(( \$(date +%s) + $BARRIER_TIMEOUT_SECS ))
peak=0
while :; do
set -- "$RUNNING_DIR"/*
if [ "\$1" = "$RUNNING_DIR/*" ] && [ ! -e "\$1" ]; then
live=0 # glob did not match (empty dir)
else
live=\$#
fi
[ "\$live" -gt "\$peak" ] && peak=\$live
[ "\$live" -ge "\$target" ] && break
[ "\$(date +%s)" -ge "\$deadline" ] && break
sleep 0.02
done
printf '%s' "\$peak" > "$PEAKS_DIR/peak.\$svc"
# (4) deregister, record end, exit.
rm -f "$RUNNING_DIR/\$svc"
printf '%s %s\n' "\$(date +%s.%N)" "\$tier" > "$EVENTS_DIR/end.\$svc"
if [ "\$svc" = "$fail_svc" ]; then exit 7; fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-timeline"
export RAILWAY_BIN="$STUB_DIR/railway-timeline"
}
# max_concurrency — print the peak number of simultaneously-alive stub
# invocations, read DETERMINISTICALLY from the per-invocation peak files the
# rendezvous barrier recorded (each invocation logged the max live count it ever
# saw). The fleet-wide peak is the max across those readings. No timestamp
# sweep, no sleep dependence: when the launcher reaches the cap, every concurrent
# invocation observes (and records) that count before any releases.
max_concurrency() {
local p max=0
for f in "$PEAKS_DIR"/peak.*; do
[ -f "$f" ] || continue
p="$(cat "$f")"
[ "$p" -gt "$max" ] && max="$p"
done
printf '%s' "$max"
}
@test "fan-out: within a tier, promotes run concurrently but never exceed PROMOTE_FANOUT" {
# 6 tier-0 services, cap 3. The rendezvous barrier makes the proof
# deterministic: with cap 3 and 6 ready, exactly 3 promotes are provably alive
# together (they only release once 3 have rendezvoused), so the recorded peak
# is EXACTLY 3 — independent of any sleep. peak must REACH the cap (proves
# parallelism, not still-serial) and never EXCEED it (proves the bound holds).
setup_timeline_stub 3 # BARRIER_TARGET = min(PROMOTE_FANOUT=3, ready=6) = 3
run env PROMOTE_FANOUT=3 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,0:t0-d,0:t0-e,0:t0-f" \
bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit on all-green fan-out, got $status: $output"
# Every service attempted.
for s in t0-a t0-b t0-c t0-d t0-e t0-f; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted: $output"
done
local peak
peak="$(max_concurrency)"
# Bound enforced: never more than PROMOTE_FANOUT in flight at once.
[ "$peak" -le 3 ] || fail "peak concurrency $peak exceeded PROMOTE_FANOUT=3 (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
# Parallelism actually happened. With the barrier this is deterministic: a
# still-serial launcher never gets >1 invocation alive, never meets the
# rendezvous, and each invocation times out with a recorded peak of 1 — so this
# assertion goes RED on a serial run (RED-on-serial preserved) with no flake.
[ "$peak" -ge 2 ] || fail "peak concurrency $peak < 2 — promotes ran serially, not in parallel (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
}
@test "fan-out: tier barrier — no tier-1 start precedes any tier-0 end" {
# Cross-tier MUST stay strictly serial: the tier-0 drain (barrier) completes
# before any tier-1 promote launches. A naive fan-out that backgrounds across
# tier boundaries would let a tier-1 start race ahead of a tier-0 end — this
# asserts it does not. BARRIER_TARGET=2 is reachable WITHIN each tier (tier-0
# has 3 ready, tier-1 has 2), so neither tier deadlocks; the rendezvous holds
# each tier's promotes alive together long enough that a cross-tier leak (a
# tier-1 start before a tier-0 end) would be plainly visible in the ordering.
setup_timeline_stub 2
run env PROMOTE_FANOUT=5 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,1:t1-g,1:t1-h" \
bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
# Latest tier-0 `end` timestamp (from per-event end.<svc> files; field 2 is the
# tier, field 1 is the epoch.ns).
local last_t0_end
last_t0_end="$(cat "$EVENTS_DIR"/end.t0-* 2>/dev/null | awk '{ print $1 }' | sort -g | tail -1)"
# Earliest tier-1 `start` timestamp.
local first_t1_start
first_t1_start="$(cat "$EVENTS_DIR"/start.t1-* 2>/dev/null | awk '{ print $1 }' | sort -g | head -1)"
[ -n "$last_t0_end" ] || fail "no tier-0 end markers: $(ls "$EVENTS_DIR")"
[ -n "$first_t1_start" ] || fail "no tier-1 start markers: $(ls "$EVENTS_DIR")"
# Barrier: every tier-1 start happens at or after the last tier-0 end.
# awk numeric compare (timestamps are epoch.ns floats). Boundary-inclusive
# (>=): a tier-1 start tying the last tier-0 end on a coarse clock is valid;
# only a start STRICTLY before a tier-0 end (b < a) is a real violation.
awk -v a="$last_t0_end" -v b="$first_t1_start" 'BEGIN { exit !(b >= a) }' \
|| fail "tier barrier violated: tier-1 start $first_t1_start did not follow tier-0 end $last_t0_end (events: $(ls "$EVENTS_DIR"))"
}
@test "fan-out: a failing tier-0 svc does not abort its siblings; run exits nonzero and reports the failure" {
# Best-effort within the (parallel) tier must survive: when one tier-0 service
# exits 7, every OTHER tier-0 service still launches + runs to completion, the
# final exit is nonzero, and the summary lists the failed svc as `<svc>=7`.
# The failing svc records its start/barrier/end markers BEFORE exiting 7, so it
# still participates in the rendezvous (BARRIER_TARGET=4 = min(cap 5, ready 4)).
setup_timeline_stub 4 "t0-b"
run env PROMOTE_FANOUT=5 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,0:t0-d" \
bash "$SCRIPT"
# All four tier-0 services launched despite t0-b failing.
for s in t0-a t0-b t0-c t0-d; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted (best-effort broken): $output"
[ -f "$EVENTS_DIR/start.$s" ] || fail "$s has no start marker (did not actually launch): $(ls "$EVENTS_DIR")"
done
# Non-zero aggregate exit because t0-b failed.
[ "$status" -ne 0 ] || fail "expected non-zero exit on a failed svc, got $status: $output"
# Summary classifies t0-b as failed with its exit code.
[[ "$output" == *"t0-b=7"* ]] || fail "failed svc t0-b=7 not reported in summary: $output"
# The greens are reported succeeded.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv="* ]] || fail "missing succeeded_csv: $output"
[[ "$output" == *"t0-a"* && "$output" == *"t0-c"* && "$output" == *"t0-d"* ]] \
|| fail "green siblings missing from succeeded_csv: $output"
[[ "$output" != *"t0-b"* ]] || fail "failed t0-b leaked into succeeded_csv: $output"
}
@test "fan-out: PROMOTE_FANOUT override is honored (cap 2 holds peak <= 2)" {
# Lowering the cap to 2 must hold the peak concurrency to 2 even with 6 ready
# services — proves the env override actually drives the launcher bound. With
# BARRIER_TARGET=2 the rendezvous deterministically reaches exactly 2 (if the
# launcher honors the override); if it ignored the cap and ran all 6, peak
# would exceed 2; if it ran serially, peak would stay 1 and the >=2 assertion
# REDs. Either way the override is proven without any sleep dependence.
setup_timeline_stub 2 # BARRIER_TARGET = min(PROMOTE_FANOUT=2, ready=6) = 2
run env PROMOTE_FANOUT=2 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,0:t0-d,0:t0-e,0:t0-f" \
bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
local peak
peak="$(max_concurrency)"
[ "$peak" -le 2 ] || fail "PROMOTE_FANOUT=2 not honored: peak concurrency $peak > 2 (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
[ "$peak" -ge 2 ] || fail "cap-2 run never reached concurrency 2 — not parallel (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
}
@test "fan-out: a PRESENT-but-EMPTY .rc (crash/disk-full mid-write) is treated as failed, not parsed as garbage" {
# The backgrounded promote_one subshell can be killed (or the disk fill) AFTER
# its <slot>.rc file exists but BEFORE the exit code is written — leaving a
# PRESENT-but-EMPTY .rc. reap_tier's `-f` check passes (the file exists), so the
# missing-file guard does NOT fire; if reap_tier then trusts the empty contents,
# `[ "$rc" -eq 0 ]` runs on rc="" and bash errors with "integer expression
# expected", and the svc gets recorded as a garbage `<svc>=` (empty rc). This
# asserts the empty/non-numeric rc is validated and folded as a real failure.
#
# We drive it with a stub that locates the script's scratch dir, pre-writes an
# EMPTY <svc>.rc, then kill -9's its parent (the promote_one subshell) so the
# script never overwrites the empty .rc with a real exit code.
cat > "$STUB_DIR/railway-emptyrc" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
# Locate promote_one's scratch dir (the sole promote-fleet.* under TMPDIR) and
# pre-seed an EMPTY .rc for this service, mimicking a write that began but never
# completed (crash/disk-full). svc names here carry no '/', so the slot == svc.
work="$(ls -d "${TMPDIR:-/tmp}"/promote-fleet.* 2>/dev/null | tail -1)"
if [ -n "$work" ] && [ -d "$work" ]; then
: > "$work/$svc.rc"
fi
# Kill the promote_one subshell so it dies BEFORE writing the real rc, leaving
# the present-but-empty .rc behind. The stub runs inside promote_one's command
# substitution: $PPID is that cmd-subst subshell; its parent (grandparent of the
# stub) is the backgrounded promote_one subshell itself — kill THAT so line 179's
# `echo "$rc" > <slot>.rc` never overwrites the empty file. -9 so no trap rewrites it.
gp="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')"
[ -n "$gp" ] && kill -9 "$gp" 2>/dev/null
kill -9 "$PPID" 2>/dev/null
exit 0
STUB
chmod +x "$STUB_DIR/railway-emptyrc"
export RAILWAY_BIN="$STUB_DIR/railway-emptyrc"
run env SERVICES_CSV="svc-empty" bash "$SCRIPT"
# (1) the run exits NONZERO — a lost/garbled result must never read as success.
[ "$status" -ne 0 ] || fail "expected non-zero exit on empty .rc, got $status: $output"
# (2) the bash `[` integer-comparison error must NOT leak (the symptom of an
# unvalidated empty rc reaching `[ "$rc" -eq 0 ]`).
[[ "$output" != *"integer expression expected"* ]] \
|| fail "empty rc leaked into integer comparison: $output"
# (3) the service is recorded as a real failure (rc coerced to 1), NOT a garbage
# empty `svc-empty=` entry.
[[ "$output" == *"svc-empty=1"* ]] || fail "empty-rc svc not recorded as svc-empty=1: $output"
[[ "$output" != *"svc-empty= "* && "$output" != *"svc-empty="$'\n'* ]] \
|| fail "garbage empty-rc entry (svc-empty=) leaked into summary: $output"
}
# --- NEW: standalone services (`s:` tier marker) -----------------------------
# A standalone service is promoted UNGATED: it is attempted regardless of any
# other service's failure (never NOT-ATTEMPTED on an unrelated failure), and its
# own failure fails the run WITHOUT gating the tiered services.
@test "U4: a standalone (s:) service is promoted even when an unrelated tier-1 service fails" {
setup_tier_stub
# tier-1 svc-fail fails (gating tier-2 svc-i1 as usual). The standalone
# svc-docs (s:) must STILL be attempted and must land in succeeded_csv.
run env CLOSURE_PLAN="s:svc-docs,0:svc-a,1:svc-fail,2:svc-i1" bash "$SCRIPT"
# Standalone attempted DESPITE the unrelated tier-1 failure.
[[ "$output" == *"STUB called for: svc-docs"* ]] \
|| fail "standalone svc-docs must be attempted despite the tier-1 failure: $output"
# Normal tier gating is unchanged: tier-2 svc-i1 is still gated.
[[ "$output" != *"STUB called for: svc-i1"* ]] \
|| fail "tier-2 svc-i1 should still be gated by the tier-1 failure: $output"
# The run still fails (svc-fail genuinely failed)...
[ "$status" -ne 0 ] || fail "expected non-zero exit (svc-fail failed), got $status: $output"
# ...but the standalone promoted successfully and is NOT in NOT-ATTEMPTED.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"svc-docs"* ]] || fail "standalone svc-docs should be in succeeded_csv: $output"
}
@test "U4: a standalone (s:) service's OWN failure fails the run but does NOT gate the tiers" {
setup_tier_stub
# The standalone svc-fail fails. Because a standalone neither gates nor is
# gated, EVERY tier must still be attempted, and the run exits non-zero.
run env CLOSURE_PLAN="s:svc-fail,0:svc-a,1:svc-h,2:svc-i1" bash "$SCRIPT"
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "standalone svc-fail not attempted: $output"
# All tiers STILL attempted — the standalone failure must not gate them.
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "tier-0 svc-a gated by standalone failure: $output"
[[ "$output" == *"STUB called for: svc-h"* ]] || fail "tier-1 svc-h gated by standalone failure: $output"
[[ "$output" == *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 gated by standalone failure: $output"
[ "$status" -ne 0 ] || fail "expected non-zero exit (standalone svc-fail failed), got $status: $output"
# The tiered services were NOT recorded NOT-ATTEMPTED.
[[ "$output" != *"NOT-ATTEMPTED"* ]] || fail "a standalone failure must not gate (NOT-ATTEMPTED) any tier: $output"
}
@test "U4: an all-green plan with a standalone (s:) member promotes everything" {
setup_tier_stub
run env CLOSURE_PLAN="s:svc-docs,0:svc-a,1:svc-h,2:svc-i1" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected exit 0 for an all-green plan, got $status: $output"
for s in svc-docs svc-a svc-h svc-i1; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted: $output"
done
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"svc-docs"* ]] || fail "standalone svc-docs should be in succeeded_csv: $output"
}