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

562 lines
27 KiB
Bash
Executable File

#!/usr/bin/env bash
# promote-fleet.sh — run `bin/railway promote <svc>` for every service in a
# CSV, best-effort, and aggregate the result.
#
# Extracted from .github/workflows/showcase_promote.yml so the loop is
# unit-testable (see __tests__/promote-fleet.bats). The bug this fixes: the
# inline workflow loop ran under `set -e`, so the FIRST service whose promote
# exited non-zero aborted the entire loop — every later service in the CSV was
# left unpromoted. With `service=all`, one chronically-red service (e.g.
# showcase-ag2) blocked promoting the whole fleet.
#
# Behavior:
# * Attempt EVERY service in the CSV regardless of individual failures.
# * Capture each service's exit code into a succeeded-set / failed-set.
# * Emit a clear end-of-run summary (stdout + GitHub Step Summary when
# $GITHUB_STEP_SUMMARY is set).
# * Exit non-zero iff ANY service failed — but only AFTER attempting all of
# them. Exit zero only when every attempted service succeeded.
#
# Usage:
# # legacy flat leaf set (backward-compat):
# SERVICES_CSV="a,b,c" [DIGEST=ref] scripts/promote-fleet.sh
# # tier-ordered closure (U4 — preferred for `all`/equivalence-gated promotes):
# CLOSURE_PLAN="0:aimock,1:harness,2:langgraph-python" [DIGEST=ref] scripts/promote-fleet.sh
#
# Env:
# CLOSURE_PLAN (optional) tier-annotated `tier:name,tier:name,...` plan from
# U3's resolve-promote-targets.sh (the `closure_plan`
# output). When set, the fleet is promoted BY TIER
# (0->1->2) and a tier GATES its dependents: if ANY
# service in a tier fails pin+verify, every LATER
# tier is NOT promoted (reported NOT-ATTEMPTED, not
# FAILED — so the operator can re-run once the
# failing tier is healthy, spec R-B). Within a tier
# the existing per-service best-effort loop is
# preserved exactly. Takes precedence over
# SERVICES_CSV when both are set.
# SERVICES_CSV (optional*) comma-separated service names to promote, flat /
# best-effort with NO tier gating (the legacy leaf
# path). Required when CLOSURE_PLAN is unset.
# DIGEST (optional) digest override; forwarded as `--digest <ref>`.
# Upstream resolve-targets already rejects
# --digest + 'all', so a populated DIGEST here
# always pairs with a single-service set.
# RAILWAY_BIN (optional) path to the railway CLI (default: sibling
# ../bin/railway). Overridable for testing.
#
# NOTE: we intentionally do NOT pass --confirm-divergence; WARN-divergence
# refusals from bin/railway are a real signal and must fail the run.
# NB: deliberately NOT `set -e` — a non-zero `bin/railway promote` for one
# service must not abort the loop. We capture each exit code explicitly and
# compute the aggregate result ourselves. `set -uo pipefail` is still safe and
# catches unset-variable / pipeline bugs in our own logic.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SHOWCASE_DIR="$(dirname "$HERE")"
RAILWAY_BIN="${RAILWAY_BIN:-$SHOWCASE_DIR/bin/railway}"
# Within-tier parallel fan-out cap. bin/railway promote is dominated by a
# serial verify_serving_digest! (~300s/service), so a fully-serial fleet
# overruns the job timeout mid-fleet. We background promote_one WITHIN a tier up
# to PROMOTE_FANOUT concurrent processes, drain at the tier boundary (the
# BARRIER), then reap each service's result. CROSS-tier ordering and
# dependent-tier gating stay strictly serial. Cap chosen to stay well under
# Railway API rate limits while cutting wall-clock to ~tier-size/cap * 300s.
PROMOTE_FANOUT="${PROMOTE_FANOUT:-5}"
if ! [ "$PROMOTE_FANOUT" -ge 1 ] 2>/dev/null; then
echo "::error::promote-fleet: PROMOTE_FANOUT='$PROMOTE_FANOUT' is not a positive integer." >&2
exit 1
fi
# Per-service result scratch dir. Backgrounded promote_one processes run in
# SUBSHELLS, so their appends to succeeded[]/failed[]/drift[] would be lost; each
# instead writes its outcome to files here (<svc>.rc / <svc>.drift / <svc>.log),
# which the parent reap phase reads back into the aggregate arrays IN INPUT
# ORDER. Cleaned up on exit.
WORK="$(mktemp -d "${TMPDIR:-/tmp}/promote-fleet.XXXXXX")"
# Invoked indirectly via the EXIT trap below, not by name. shellcheck flags this
# differently across versions: 0.9.0 (the ubuntu-24.04 CI runner) emits SC2317
# ("command appears unreachable") on the body, while 0.10.0+ emits SC2329
# ("function never invoked") on the definition. Disable both so the directive is
# clean on every shellcheck the fleet runs (local + CI).
# shellcheck disable=SC2317,SC2329
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
# ── Input mode resolution ───────────────────────────────────────────────────
# Two input shapes:
# * CLOSURE_PLAN — tier-annotated `tier:name,tier:name,...` (U3 output). When
# present, the fleet is promoted BY TIER with dependent-tier gating.
# * SERVICES_CSV — flat comma-separated leaf set (legacy / backward-compat),
# best-effort with NO tier gating.
# CLOSURE_PLAN takes precedence when both are set.
if [ -z "${CLOSURE_PLAN:-}" ] && [ -z "${SERVICES_CSV:-}" ]; then
echo "::error::promote-fleet: neither CLOSURE_PLAN nor SERVICES_CSV is set; nothing to promote." >&2
exit 1
fi
# Validate the railway CLI up front. Without this, a missing/non-executable
# binary makes EVERY iteration fail with 126/127, misattributing a single
# environment error as N per-service promote failures. `-x` covers an absolute
# path; `command -v` covers RAILWAY_BIN being a bare PATH command name.
if [ ! -x "$RAILWAY_BIN" ] && ! command -v "$RAILWAY_BIN" >/dev/null 2>&1; then
echo "::error::promote-fleet: RAILWAY_BIN '$RAILWAY_BIN' is missing or not executable; cannot promote." >&2
exit 1
fi
succeeded=() # service names that actually pinned (the closure subset)
failed=() # "svc=exitcode" entries
not_attempted=() # services in tiers gated out by an earlier-tier failure
drift=() # aggregated STAGING_DRIFT_MARKER payloads across the fleet
# trim <var-of-string> -> echoes the string with leading/trailing whitespace
# stripped. `IFS=',' read` does NOT trim, so a token like " svc-c" (leading
# space) would be promoted as a bogus service name; strip it consistently on
# both the flat and the tiered paths.
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}" # strip leading whitespace
s="${s%"${s##*[![:space:]]}"}" # strip trailing whitespace
printf '%s' "$s"
}
# svc_slot <svc> -> echo a filesystem-safe scratch key for <svc>. Service names
# are simple DNS-ish labels (e.g. showcase-ag2), but defensively replace any
# `/` so a stray name can never escape $WORK.
svc_slot() {
printf '%s' "${1//\//_}"
}
# failed_set_to_json <svc=rc>... -> emit the failed[] array consumed by the
# showcase_promote_notify.yml renderer: one object per entry, each
# `{service, exit, category}`. promote-fleet tracks only `svc=exitcode` (no
# failure taxonomy), so every entry gets the default category "promote-failed".
# The renderer renders these as "• `<service>` — exit <exit> (<category>)".
# Kept as a top-level function (not an inline `case` inside `$( )`) because a
# `case` glob ending in `)` inside command substitution trips the bash parser
# on some versions ("syntax error near unexpected token `;;'").
failed_set_to_json() {
local entry svc rc
for entry in "$@"; do
[ -n "$entry" ] || continue
# Split on the LAST '=' so the exit code is always the trailing field even
# if a service name (defensively) contained '='.
svc="${entry%=*}"
rc="${entry##*=}"
# Coerce a non-numeric / empty rc to 1 so the jq --argjson below always
# gets a valid integer (a malformed entry must not crash the JSON build).
case "$rc" in
''|*[!0-9-]*) rc=1 ;;
esac
jq -nc --arg s "$svc" --argjson e "$rc" \
'{service: $s, exit: $e, category: "promote-failed"}'
done | jq -sc '.'
}
# promote_one <svc> -> promote a single service best-effort. Writes its outcome
# to the $WORK scratch dir instead of mutating arrays, so it is safe to run in a
# BACKGROUNDED subshell (where array appends would be lost):
# $WORK/<svc>.rc the railway exit code (0 = pinned)
# $WORK/<svc>.log the full captured promote output (streamed contiguously by
# the reap phase, prefixed [<svc>], so interleaved parallel
# output stays readable)
# $WORK/<svc>.drift one STAGING_DRIFT_MARKER payload per line (absent if none)
# Returns the railway exit code. The per-service BEHAVIOR (args, PIPESTATUS rc
# capture, drift scan, OK/::error:: lines) is preserved exactly; only the result
# SINK changed from arrays to files so the parent can reap them in input order.
promote_one() {
local svc="$1"
local slot args out rc line
slot="$(svc_slot "$svc")"
args=(promote "$svc" --yes --non-interactive)
if [ -n "${DIGEST:-}" ]; then
args+=(--digest "$DIGEST")
fi
# Capture this service's full output into its own log file. We do NOT tee to
# the live terminal here: under fan-out, N services stream at once and their
# lines would interleave illegibly. The reap phase emits each <svc>.log
# CONTIGUOUSLY (prefixed [<svc>]) after the tier drains, preserving the
# readable per-service block the serial path produced. PIPESTATUS[0] is the
# railway exit code (the redirect/sed in the drift scan never runs in the same
# pipe, so rc is the railway rc directly).
out="$("$RAILWAY_BIN" "${args[@]}" 2>&1)"
rc=$?
{
echo "==> $RAILWAY_BIN ${args[*]}"
printf '%s\n' "$out"
if [ "$rc" -eq 0 ]; then
echo " OK: $svc"
else
echo "::error::promote failed for '$svc' (exit $rc)"
fi
} > "$WORK/$slot.log"
# Collect any drift marker(s) emitted for this service. The marker payload is
# everything after "STAGING_DRIFT_MARKER: ". Promote still SUCCEEDS on drift
# (it pins staging's running digest) — this is a warning surface, not a gate.
printf '%s\n' "$out" | sed -n 's/^STAGING_DRIFT_MARKER: //p' > "$WORK/$slot.drift"
echo "$rc" > "$WORK/$slot.rc"
return "$rc"
}
# promote_tier <gated> <svc...> — promote every service in one tier best-effort
# (unless <gated> is 1, in which case the tier is gated out by an earlier-tier
# failure and its services are recorded NOT-ATTEMPTED). Sets the GLOBAL
# `tier_had_failure` to 1 if this tier itself had a promote failure (so the
# caller can gate the NEXT tier), else 0. Best-effort within the tier is
# preserved: every member is attempted even after a sibling fails.
#
# WITHIN-TIER PARALLELISM: services in a non-gated tier are promoted with
# bounded concurrency (PROMOTE_FANOUT). promote_one runs in a backgrounded
# SUBSHELL writing its result to $WORK/<svc>.rc/.drift/.log; when the in-flight
# count reaches the cap we `wait` the oldest PID (bash 3.2-safe — NO `wait -n`,
# NO `declare -n`). After launching all members we drain every remaining PID:
# that drain is the TIER BARRIER. reap_tier then folds the per-service files
# into the aggregate arrays IN INPUT ORDER, preserving the serial path's
# ordering, drift aggregation, and best-effort + nonzero-iff-any-failed
# semantics. CROSS-tier ordering stays serial (the caller reaps before the next
# tier launches). The flat (SERVICES_CSV) path reuses this as a single ungated
# tier so it benefits from the same fan-out.
promote_tier() {
local gated_in="$1"; shift
local svc pid
local launched=() # service names launched this tier, IN INPUT ORDER
local pids=() # background PIDs, parallel-indexed with launched[]
local inflight=0
tier_had_failure=0
for svc in "$@"; do
# Skip the empty arg an empty tier yields via `${arr[@]:-}` on bash 3.2
# (and any blank that slipped through). A blank is never a real service.
[ -n "$svc" ] || continue
if [ "$gated_in" -ne 0 ]; then
not_attempted+=("$svc")
continue
fi
# Throttle: once PROMOTE_FANOUT promotes are in flight, block on the OLDEST
# outstanding PID before launching the next. Plain `wait <pid>` is bash
# 3.2-safe; `wait -n` (4.3+) is deliberately avoided. This is a simple
# oldest-first drain, not a true "any-finished" reaper, but it bounds peak
# concurrency to the cap exactly while keeping the launch order stable.
if [ "$inflight" -ge "$PROMOTE_FANOUT" ]; then
local oldest_idx=$(( ${#pids[@]} - inflight ))
wait "${pids[$oldest_idx]}"
inflight=$(( inflight - 1 ))
fi
# Background promote_one in a subshell. Its array appends would be lost, but
# it writes <svc>.rc/.drift/.log to $WORK which reap_tier reads back.
promote_one "$svc" &
pids+=("$!")
launched+=("$svc")
inflight=$(( inflight + 1 ))
done
# TIER BARRIER: drain every remaining in-flight promote before reaping or
# advancing to the next tier. Wait on ALL launched PIDs (already-reaped ones
# return immediately — harmless).
for pid in "${pids[@]:-}"; do
[ -n "$pid" ] && wait "$pid"
done
reap_tier "${launched[@]:-}"
}
# reap_tier <svc...> — fold each launched service's $WORK result files into the
# aggregate arrays IN THE GIVEN (input) ORDER, so succeeded[]/failed[]/drift[]
# match the serial path's ordering regardless of completion order. Emits each
# service's captured log CONTIGUOUSLY, prefixed `[<svc>]`, then OK/::error::
# accounting identical to the old inline path. Sets tier_had_failure on any
# nonzero rc.
reap_tier() {
local svc slot rc line
for svc in "$@"; do
[ -n "$svc" ] || continue
slot="$(svc_slot "$svc")"
# Emit this service's full captured output as one contiguous block so a
# parallel tier's logs stay readable (prefixed with the service name).
if [ -f "$WORK/$slot.log" ]; then
while IFS= read -r line; do
echo "[$svc] $line"
done < "$WORK/$slot.log"
fi
# Aggregate drift markers (one payload per line; file may be empty/absent).
if [ -s "$WORK/$slot.drift" ]; then
while IFS= read -r line; do
[ -n "$line" ] && drift+=("$line")
done < "$WORK/$slot.drift"
fi
# Exit code: a MISSING .rc means the backgrounded promote_one died before
# writing it (crash/kill) — treat that as a failure rather than silently
# dropping the service, so a lost promote never reads as a phantom success.
if [ -f "$WORK/$slot.rc" ]; then
rc="$(cat "$WORK/$slot.rc")"
# A PRESENT but EMPTY-or-NON-NUMERIC .rc means the promote subshell was
# killed (or the disk filled) mid-write — the file exists but its exit code
# never landed. Without this guard `rc` could be "" or garbage, and the
# `[ "$rc" -eq 0 ]` below would error ("integer expression expected") and
# mis-record the service as a phantom `<svc>=` (empty rc). Coerce any
# non-integer rc to a failure, mirroring the missing-file branch. The
# `case` glob is bash-3.2-safe (no `[[ =~ ]]`, matching the file's style).
case "$rc" in
''|*[!0-9-]*)
rc=1
echo "::error::promote-fleet: malformed result recorded for '$svc' (promote process died mid-write, leaving an empty or non-numeric exit code); treating as failed."
;;
esac
else
rc=1
echo "::error::promote-fleet: no result recorded for '$svc' (promote process died before writing its exit code); treating as failed."
fi
if [ "$rc" -eq 0 ]; then
succeeded+=("$svc")
else
failed+=("$svc=$rc")
tier_had_failure=1
fi
done
}
if [ -n "${CLOSURE_PLAN:-}" ]; then
# ── Tier-ordered closure path (U4) ──────────────────────────────────────────
# Parse `tier:name` tokens into per-tier service lists, then promote tier 0,
# then 1, then 2. A tier GATES its dependents: once any tier records a
# failure, every LATER tier is NOT attempted — its services are recorded as
# not_attempted[] (distinct from failed[]) so the operator can re-run them
# once the failing tier is healthy (spec R-B). A stale aimock/harness under
# fresh integrations is a non-equivalent prod, so the leaf tiers must not pin.
# Three per-tier service lists. NB we deliberately AVOID bash 4.3 `declare -n`
# namerefs here: CI runs on ubuntu bash 5 but contributors/maintainers run the
# bats suite on macOS system bash 3.2, which lacks namerefs. Iterate each tier
# array explicitly so the script stays portable to bash 3.2.
tier0=()
tier1=()
tier2=()
standalone_svcs=() # `s:`-marked services: promoted UNGATED (never gate / gated)
IFS=',' read -ra PLAN_TOKENS <<< "$CLOSURE_PLAN"
for tok in "${PLAN_TOKENS[@]}"; do
tok="$(trim "$tok")"
[ -n "$tok" ] || continue
# Split `tier:name`; the tier is the part before the FIRST colon. A
# standalone service carries the `s` marker instead of a numeric tier.
tier="${tok%%:*}"
svc="$(trim "${tok#*:}")"
[ -n "$svc" ] || continue
case "$tier" in
0) tier0+=("$svc") ;;
1) tier1+=("$svc") ;;
2) tier2+=("$svc") ;;
s) standalone_svcs+=("$svc") ;;
*)
echo "::error::promote-fleet: CLOSURE_PLAN token '$tok' has an unknown tier '$tier' (expected 0, 1, 2, or s)." >&2
exit 1
;;
esac
done
# Promote in strict tier order (0 -> 1 -> 2), gating each tier's dependents on
# ANY earlier-tier failure. `${arr[@]:-}` keeps the empty-array expansion safe
# under `set -u` on bash 3.2 (an empty tier expands to a single empty arg,
# which promote_tier skips via promote_one's no-op on "" — see below).
gated=0
tier_had_failure=0
# Standalone services FIRST and ALWAYS ungated. Their failures still land in
# failed[] (so the run exits non-zero), but we deliberately do NOT fold their
# tier_had_failure into `gated`: a standalone leaf neither gates a dependent
# nor is gated by an unrelated failure. The reset below ensures a standalone
# failure cannot leak into tier 0's gating decision.
promote_tier 0 "${standalone_svcs[@]:-}"
tier_had_failure=0
promote_tier "$gated" "${tier0[@]:-}"
[ "$tier_had_failure" -ne 0 ] && gated=1
promote_tier "$gated" "${tier1[@]:-}"
[ "$tier_had_failure" -ne 0 ] && gated=1
promote_tier "$gated" "${tier2[@]:-}"
else
# ── Flat leaf path (legacy / backward-compat) ──────────────────────────────
# No tier gating: every service attempted best-effort, identical to the
# pre-U4 behavior. not_attempted[] stays empty on this path. We route the
# trimmed leaf set through promote_tier as a single UNGATED tier (gated=0) so
# the flat path gets the same bounded fan-out as a closure tier — best-effort,
# input-order aggregation, and drift handling are all preserved by reap_tier.
flat_svcs=()
IFS=',' read -ra SVCS <<< "$SERVICES_CSV"
for svc in "${SVCS[@]}"; do
# Trim BEFORE the empty-check so a whitespace-only token is also skipped.
svc="$(trim "$svc")"
# Guard against empty tokens from a stray/trailing comma (or a
# whitespace-only token) in the CSV.
[ -n "$svc" ] || continue
flat_svcs+=("$svc")
done
tier_had_failure=0
promote_tier 0 "${flat_svcs[@]:-}"
fi
# Guard against input that parsed to ONLY empty/whitespace tokens (e.g. ",,"
# or " , ", or a CLOSURE_PLAN of all blank tokens). Such input passes the
# upfront empty-check, skips every token in the loop, and would otherwise exit 0
# claiming "All services promoted successfully" — a silent no-op false success.
# If zero services were actually attempted, fail loud.
#
# NOTE: not_attempted[] (tiers gated out by an earlier-tier failure) is NOT an
# "attempt" — those services were deliberately NOT promoted. But a gated run
# ALWAYS has at least one failed[] entry (the tier failure that triggered the
# gate), so `attempted` is non-zero there; this guard only trips on genuinely
# empty input.
attempted=$(( ${#succeeded[@]} + ${#failed[@]} ))
if [ "$attempted" -eq 0 ]; then
if [ -n "${CLOSURE_PLAN:-}" ]; then
echo "::error::promote-fleet: CLOSURE_PLAN contained no usable service names (only empty/whitespace tokens); nothing was promoted." >&2
else
echo "::error::promote-fleet: SERVICES_CSV contained no usable service names (only empty/whitespace tokens); nothing was promoted." >&2
fi
exit 1
fi
# ── Step output ────────────────────────────────────────────────────────────
# Export the SUCCEEDED set (comma-joined) so the downstream verify-prod job can
# scope its prod verification to only the services that actually promoted — not
# the full requested set (which would include any failed service and guarantee
# a red verify) and not nothing (which would skip verification of the services
# that DID promote). Guarded so a local/bats run with no $GITHUB_OUTPUT is a
# no-op. Uses the same `key=value >> "$GITHUB_OUTPUT"` idiom as the workflow.
if [ -n "${GITHUB_OUTPUT:-}" ]; then
succeeded_csv=""
if [ "${#succeeded[@]}" -gt 0 ]; then
succeeded_csv=$(IFS=,; echo "${succeeded[*]}")
fi
# Fail loud on a failed redirect. Under `set -uo pipefail` (no `-e`) a failed
# append would otherwise be silently discarded, leaving verify-prod scoping
# unreliable (an absent succeeded_csv key it depends on).
if ! echo "succeeded_csv=$succeeded_csv" >> "$GITHUB_OUTPUT"; then
echo "::error::promote-fleet: failed to write succeeded_csv to \$GITHUB_OUTPUT ('$GITHUB_OUTPUT')." >&2
exit 1
fi
# Aggregated staging-drift payload (one line, services semicolon-joined) so
# the notify job can fold "staging was NOT serving :latest" into the Slack
# message. Empty when no service drifted (the common case).
staging_drift=""
if [ "${#drift[@]}" -gt 0 ]; then
# Join with "; " between entries. `${drift[*]}` with IFS='; ' would only use
# the FIRST IFS char (';'), dropping the space; build the separator explicitly.
printf -v staging_drift '%s; ' "${drift[@]}"
staging_drift="${staging_drift%; }"
fi
if ! echo "staging_drift=$staging_drift" >> "$GITHUB_OUTPUT"; then
echo "::error::promote-fleet: failed to write staging_drift to \$GITHUB_OUTPUT ('$GITHUB_OUTPUT')." >&2
exit 1
fi
# ── results JSON for the three-variant Slack renderer ──────────────────────
# Build the base64-encoded `results` blob consumed by
# .github/workflows/showcase_promote_notify.yml (schema_version=1). That
# renderer is the SSOT for the schema; the canonical fields it decodes are:
# .schema_version must equal "1" (else the renderer aborts gracefully)
# .succeeded[] succeeded-set; the renderer reads `.succeeded | length`
# only (count), never per-entry fields — we emit objects
# `{service}` for symmetry with failed[] / future use.
# .failed[] each `{service, exit, category}` — the renderer renders
# "• `<service>` — exit <exit> (<category>)" bullets and
# uses `category == "truncation-suffix"` as a sentinel.
# .abort_reason "fleet-preflight" | "per-service" | "" — drives the
# total-abort branch + *Reason:* line. promote-fleet has
# no preflight/abort concept of its own (bin/railway owns
# §7 preflight), so we leave it "" and let the renderer's
# succeeded==0 && failed>0 defensive branch render the
# total-failure variant.
# CATEGORY CAVEAT: promote-fleet only tracks `svc=exitcode` (no failure
# taxonomy), so every failed entry gets the sane default category
# "promote-failed". A richer taxonomy would live upstream in bin/railway.
#
# The run-context fields the renderer also reads — run_id, trigger,
# operator_email/git_name, elapsed_seconds, pre_staging — are NOT known here
# (they are properties of the dispatching RUN, not the promote loop). The
# workflow that dispatches the renderer merges those into this blob; see
# showcase_promote.yml's "Build notify payload" step. We still emit a valid
# schema_version=1 blob with succeeded[]/failed[] so promote-fleet is the
# SSOT for the result set and the bats suite can assert it directly.
succeeded_json="[]"
if [ "${#succeeded[@]}" -gt 0 ]; then
succeeded_json=$(printf '%s\n' "${succeeded[@]}" | jq -R '{service: .}' | jq -sc '.')
fi
failed_json="[]"
if [ "${#failed[@]}" -gt 0 ]; then
failed_json=$(failed_set_to_json "${failed[@]}")
fi
results_json=$(jq -nc \
--argjson succeeded "$succeeded_json" \
--argjson failed "$failed_json" \
'{schema_version: 1, abort_reason: "", succeeded: $succeeded, failed: $failed}')
# base64-encode (single line; the renderer's `base64 -d` tolerates wrapping
# but a single line keeps the GITHUB_OUTPUT key=value contract trivially
# intact — no embedded newline to corrupt the output map).
results_b64=$(printf '%s' "$results_json" | base64 | tr -d '\n')
if ! echo "results_b64=$results_b64" >> "$GITHUB_OUTPUT"; then
echo "::error::promote-fleet: failed to write results_b64 to \$GITHUB_OUTPUT ('$GITHUB_OUTPUT')." >&2
exit 1
fi
fi
# ── Summary ──────────────────────────────────────────────────────────────
emit() {
# Echo to stdout AND, when running in GitHub Actions, append to the step
# summary so the result is visible in the run UI.
echo "$1"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$1" >> "$GITHUB_STEP_SUMMARY"
fi
}
emit "## Promote fleet summary"
# Report services ACTUALLY attempted (succeeded + failed), not the raw input
# token count — which would over-count empty/whitespace tokens skipped above.
if [ -n "${CLOSURE_PLAN:-}" ]; then
emit "Attempted ${attempted} service(s) from CLOSURE_PLAN (tier-ordered)."
else
emit "Attempted ${attempted} service(s) from SERVICES_CSV."
fi
if [ "${#succeeded[@]}" -gt 0 ]; then
emit "SUCCEEDED (${#succeeded[@]}): ${succeeded[*]}"
else
emit "SUCCEEDED (0): (none)"
fi
if [ "${#drift[@]}" -gt 0 ]; then
emit ""
emit "⚠️ STAGING DRIFT (${#drift[@]}): staging was NOT serving current :latest for — ${drift[*]}"
emit "Promote pinned prod to staging's RUNNING digest (what was seen in staging); investigate the drift."
fi
# NOT-ATTEMPTED: services in tiers gated out by an earlier-tier failure. These
# are DISTINCT from FAILED — the services were never promoted (so prod was left
# untouched), and the operator can re-run them once the failing tier is healthy
# (spec R-B). Only populated on the tier-ordered CLOSURE_PLAN path.
if [ "${#not_attempted[@]}" -gt 0 ]; then
emit ""
emit "NOT-ATTEMPTED (${#not_attempted[@]}): ${not_attempted[*]}"
emit "These tiers were gated by an earlier-tier failure and NOT promoted; re-run once the failing tier is healthy."
fi
if [ "${#failed[@]}" -gt 0 ]; then
emit "FAILED (${#failed[@]}): ${failed[*]}"
emit ""
emit "One or more services failed to promote; marking the run failed so the notify job fires."
exit 1
fi
emit "FAILED (0): (none)"
emit ""
emit "All services promoted successfully."
exit 0