Skip to content

scenario-suite

scenario-suite #220

name: scenario-suite
# End-to-end validation driver for the 3-environment (test, staging, prod)
# cascade example. It commits to trunk, lets orchestrate run, then walks the
# promotion ladder one step at a time (test -> staging -> prod). After each
# step it reads the manifest state for all three environments, the git tags,
# and the GitHub releases, and asserts the full stage table.
#
# This repository exercises the inline-callback surface: builds and deploys
# declared with inline `run:`/`shell:` (not external reusable workflows), with
# per-callback secrets, OIDC permissions (id-token: write), runs_on, per-callback
# concurrency, timeout_minutes, retries, depends_on, auto_commits, and a
# pre-build validate gate. Where those features are observable from the run
# graph this driver checks them.
#
# Run manually (workflow_dispatch), nightly, or when cascade dispatches a
# cascade-revalidate event. It never hardcodes commit SHAs; it asserts the
# SHAPE of the state (versions present, prerelease/published flags, tag set).
on:
workflow_dispatch:
inputs:
cascade_version:
description: 'cascade rc tag to self-repin to (e.g. v0.16.6). Empty runs committed defaults.'
required: false
default: ''
cascade_version_sha:
description: 'Peeled commit SHA paired with cascade_version. Empty runs committed defaults.'
required: false
default: ''
schedule:
- cron: '0 7 * * *'
repository_dispatch:
types: [cascade-revalidate]
permissions:
contents: write
actions: write
pull-requests: write
concurrency:
group: scenario-suite-${{ github.ref }}
cancel-in-progress: false
env:
POLL_ATTEMPTS: '20'
POLL_INTERVAL: '60'
jobs:
# ---------------------------------------------------------------------
# Gen-time wiring checks. Assert the structural wiring of the COMMITTED
# orchestrate.yaml that proves several manifest features without a live run.
# The committed file is exactly what runs, so static yq assertions on it are
# deterministic. These check stable generated structure, not run-time
# observations:
# - callback.permissions_oidc: id-token:write is scoped to the caller job
# that needs it (per-job least privilege), not the workflow top level.
# - secrets.inherit: the base caller job emits `secrets: inherit`.
# - build.depends_on_order: build-app needs build-base AND its if gates
# on needs.build-base.result == 'success' (base -> app ordering).
# - build.retries: the retry-shim jobs are emitted with the correct
# re-invoke conditions.
gen-time:
name: gen-time wiring checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Assert generated orchestrate wiring
run: |
set -euo pipefail
gfail() { echo "::error::$1"; exit 1; }
# Assert directly against the COMMITTED orchestrate.yaml. This is the
# exact file that runs in production, so static assertions on it are
# deterministic. yq (mikefarah/yq) ships preinstalled on the
# ubuntu-latest image; verify it before use.
command -v yq >/dev/null \
|| gfail "yq not found on runner"
ORCH=.github/workflows/orchestrate.yaml
[ -f "$ORCH" ] || gfail "committed orchestrate.yaml not found"
# callback.permissions_oidc: id-token:write is scoped to the caller
# job that needs it, not unioned onto the workflow top level. A
# reusable-workflow-caller job (uses:) carries its own job-level
# permissions block, and id-token: write on that caller propagates
# OIDC at runtime (GitHub-supported). This keeps the top level at
# least privilege: id-token never leaks to every job.
baseidtoken=$(yq '.jobs."build-base".permissions."id-token" // ""' "$ORCH")
[ "$baseidtoken" = "write" ] \
|| gfail "expected build-base permissions.id-token=write, got '$baseidtoken'"
basecontents=$(yq '.jobs."build-base".permissions."contents" // ""' "$ORCH")
[ "$basecontents" = "read" ] \
|| gfail "expected build-base permissions.contents=read, got '$basecontents'"
topidtoken=$(yq '.permissions."id-token" // ""' "$ORCH")
[ -z "$topidtoken" ] \
|| gfail "top-level permissions.id-token must be absent (least privilege), got '$topidtoken'"
# secrets.inherit: the base caller job emits the literal `secrets: inherit`.
basesecrets=$(yq '.jobs."build-base".secrets // ""' "$ORCH")
[ "$basesecrets" = "inherit" ] \
|| gfail "expected build-base secrets=inherit, got '$basesecrets'"
# build.depends_on_order: build-app needs build-base AND gates its if on
# the base callback succeeding (proves base -> app ordering + skip-gate).
yq '.jobs."build-app".needs[]' "$ORCH" | grep -qx 'build-base' \
|| gfail "build-app.needs must contain build-base"
appif=$(yq '.jobs."build-app".if' "$ORCH")
echo "$appif" | grep -q "needs.build-base.result == 'success'" \
|| gfail "build-app.if must gate on needs.build-base.result == 'success'"
# build.retries: base declares retries: 2, so two shim jobs are emitted,
# each re-invoking on the prior attempt's failure.
[ "$(yq '.jobs | has("build-base-retry-1")' "$ORCH")" = "true" ] \
|| gfail "build-base-retry-1 shim job missing"
[ "$(yq '.jobs | has("build-base-retry-2")' "$ORCH")" = "true" ] \
|| gfail "build-base-retry-2 shim job missing"
r1if=$(yq '.jobs."build-base-retry-1".if' "$ORCH")
echo "$r1if" | grep -q "needs.build-base.result == 'failure'" \
|| gfail "build-base-retry-1.if must re-invoke on build-base failure, got '$r1if'"
echo "$r1if" | grep -q '!cancelled()' \
|| gfail "build-base-retry-1.if must carry the !cancelled() status gate, got '$r1if'"
yq '.jobs."build-base-retry-2".needs[]' "$ORCH" | grep -qx 'build-base-retry-1' \
|| gfail "build-base-retry-2.needs must contain build-base-retry-1"
r2if=$(yq '.jobs."build-base-retry-2".if' "$ORCH")
echo "$r2if" | grep -q "needs.build-base-retry-1.result == 'failure'" \
|| gfail "build-base-retry-2.if must re-invoke on retry-1 failure, got '$r2if'"
echo "$r2if" | grep -q '!cancelled()' \
|| gfail "build-base-retry-2.if must carry the !cancelled() status gate, got '$r2if'"
echo "gen-time wiring checks passed"
suite:
name: 3env scenario suite
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
# Captured before anything is dispatched. The reconcile job enumerates
# every run this repo produced at or after this instant and fails if any
# is unaccounted for in the ledger. Recorded first so no run the suite
# causes can fall outside the window.
window-start: ${{ steps.window.outputs.window-start }}
steps:
# The reconcile window opens here, before the first dispatch or merge.
# Every run the suite goes on to cause lands at or after this timestamp,
# so the reconcile job sees all of them.
- name: Open reconcile window
id: window
run: |
set -euo pipefail
WINDOW_START="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "window-start=$WINDOW_START" >> "$GITHUB_OUTPUT"
echo "reconcile window opened at $WINDOW_START"
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.CASCADE_STATE_TOKEN }}
- name: Setup cascade CLI
uses: stablekernel/cascade/.github/actions/setup-cli@v1.1.2
with:
token: ${{ secrets.CASCADE_STATE_TOKEN }}
version: ${{ inputs.cascade_version || 'v0.8.0' }}
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Log cascade version mode
run: |
if [ -n "${{ inputs.cascade_version }}" ]; then
echo "Running against dispatched rc: ${{ inputs.cascade_version }} (sha ${{ inputs.cascade_version_sha }})"
else
echo "Running against committed defaults (no rc dispatched)"
fi
- name: Self-repin manifest to the dispatched rc
uses: stablekernel/cascade/.github/actions/fleet-repin@main
with:
cascade_version: ${{ inputs.cascade_version }}
cascade_version_sha: ${{ inputs.cascade_version_sha }}
token: ${{ secrets.CASCADE_STATE_TOKEN }}
# ---------------------------------------------------------------------
# Shared helpers: bounded waits and shape assertions. Defined once as a
# sourced script so every step uses the same polling + failure contract.
# ---------------------------------------------------------------------
- name: Write helpers
run: |
cat > "$RUNNER_TEMP/helpers.sh" <<'HELPERS'
set -euo pipefail
REPO="${GITHUB_REPOSITORY}"
fail() { echo "::error::$1"; exit 1; }
# Close any open cascade-hotfix / cascade-hotfix-conflict PR before the
# hotfix stage runs. The generated plan job passes --repo, which arms
# the single-flight gate: it aborts a fresh hotfix while an open hotfix
# PR already targets the env. A prior run that ended mid-hotfix can
# strand such a PR, so close each one and drop its head branch to
# normalize state. Scoped to the two hotfix labels; other open PRs are
# left untouched.
close_open_hotfix_prs() {
local label num
for label in cascade-hotfix cascade-hotfix-conflict; do
for num in $(gh pr list --repo "$REPO" --state open --label "$label" \
--json number --jq '.[].number' 2>/dev/null || true); do
gh pr close "$num" --repo "$REPO" --delete-branch \
&& echo "closed stale $label PR #$num" \
|| echo "::warning::could not close $label PR #$num"
done
done
}
# Wait for the $wf run that THIS suite dispatched, correlated by the
# dispatch timestamp passed as $2 (RFC3339, UTC). A workflow_dispatch
# run creates no new commit, so it is keyed on creation time rather
# than a head SHA. Poll for that run to appear, then block on its
# databaseId and require a success conclusion. Bounded; emits
# ::error:: and fails on timeout. The resolved run id is written to
# $RESOLVED_RUN_ID_FILE so the caller can register it with the
# reconcile ledger.
wait_for_workflow() {
local wf="$1"
local ts="$2"
local i run_id=""
for ((i=0; i<POLL_ATTEMPTS; i++)); do
run_id=$(gh run list --workflow "$wf" --branch main \
--created ">=$ts" --limit 1 --json databaseId \
--jq '.[0].databaseId // empty' 2>/dev/null || echo "")
[[ -n "$run_id" ]] && break
sleep "$POLL_INTERVAL"
done
[[ -n "$run_id" ]] || fail "no $wf run found for dispatch $ts"
[[ -n "${RESOLVED_RUN_ID_FILE:-}" ]] && echo "$run_id" > "$RESOLVED_RUN_ID_FILE"
gh run watch "$run_id" --interval 60 --exit-status \
|| fail "$wf run $run_id did not conclude success"
echo "$wf completed: run $run_id"
}
# Re-read the manifest state from trunk after cascade has written it.
# orchestrate's finalize job pushes the state as a separate
# "chore: update state [skip ci]" commit on main. gh run watch can
# return before that push is fetchable, so hard-reset the local tree
# to origin/main rather than checking out a single path.
refresh_manifest() {
git fetch origin main --quiet
git reset --hard origin/main
}
# status_version <env> -> prints the recorded version for an env, or
# empty string when unset.
status_version() {
cascade status env "$1" --json -c .github/manifest.yaml 2>/dev/null \
| jq -r '.state.version // ""'
}
# Assert an env has a non-empty recorded version. The finalize state
# push lands as an async [skip ci] commit, so poll trunk until it
# appears rather than racing the first read.
assert_env_set() {
local env="$1"
local v
for _ in $(seq 1 2); do
refresh_manifest
v=$(status_version "$env")
[[ -n "$v" ]] && break
sleep 60
done
[[ -n "$v" ]] || fail "expected state.$env to carry a version, got empty"
echo "state.$env version=$v"
}
# Assert an env carries no version yet (unchanged).
assert_env_unset() {
local env="$1"
local v
v=$(status_version "$env")
[[ -z "$v" ]] || fail "expected state.$env unset, got version=$v"
echo "state.$env unset (as expected)"
}
# Assert the most recent GitHub release matches a prerelease/published
# expectation. mode is one of: draft | prerelease | published.
assert_release_mode() {
local mode="$1"
local json
json=$(gh release list --limit 1 --json tagName,isDraft,isPrerelease 2>/dev/null || echo '[]')
[[ "$(echo "$json" | jq 'length')" -ge 1 ]] || fail "no GitHub release found, expected $mode"
local draft prerelease tag
tag=$(echo "$json" | jq -r '.[0].tagName')
draft=$(echo "$json" | jq -r '.[0].isDraft')
prerelease=$(echo "$json" | jq -r '.[0].isPrerelease')
case "$mode" in
draft) [[ "$draft" == "true" ]] || fail "release $tag expected draft, got draft=$draft" ;;
prerelease) [[ "$prerelease" == "true" && "$draft" == "false" ]] \
|| fail "release $tag expected prerelease, got draft=$draft prerelease=$prerelease" ;;
published) [[ "$prerelease" == "false" && "$draft" == "false" ]] \
|| fail "release $tag expected published, got draft=$draft prerelease=$prerelease" ;;
*) fail "unknown release mode $mode" ;;
esac
echo "release $tag mode=$mode OK"
}
# Assert at least one rc.* tag exists (shape, not exact value).
assert_rc_tag_present() {
git fetch --tags --quiet
git tag --list 'v*-rc.*' | grep -q . || fail "expected an rc.* tag, found none"
echo "rc.* tag present: $(git tag --list 'v*-rc.*' | tail -1)"
}
# Assert a final (non-rc) vX.Y.Z tag exists and rc tags were cleaned.
assert_final_tag_published() {
git fetch --tags --quiet
git tag --list 'v[0-9]*' | grep -vE 'rc\.' | grep -q . \
|| fail "expected a published vX.Y.Z tag, found none"
echo "final tag present: $(git tag --list 'v[0-9]*' | grep -vE 'rc\.' | tail -1)"
}
# state_field <env> <jq-path> -> prints a scalar field from the
# recorded env state (e.g. .state.sha, .state.ref, .state.base_sha).
state_field() {
cascade status env "$1" --json -c .github/manifest.yaml 2>/dev/null \
| jq -r "$2 // \"\""
}
# Find the orchestrate run for a given head SHA. Prints the databaseId
# or empty. Bounded poll; never matches a stale or prior run because it
# keys on the exact post-merge HEAD SHA.
orchestrate_run_for_sha() {
local sha="$1" i run_id=""
for ((i=0; i<POLL_ATTEMPTS; i++)); do
run_id=$(gh run list --workflow=orchestrate.yaml --branch=main \
--json databaseId,headSha \
--jq ".[] | select(.headSha==\"$sha\") | .databaseId" 2>/dev/null \
| head -n1)
[[ -n "$run_id" ]] && break
sleep "$POLL_INTERVAL"
done
echo "$run_id"
}
# Assert that EVERY build/deploy job in an orchestrate run concluded
# skipped (not success). Proves on_failure: abort short-circuited the
# downstream graph when validate failed. Setup/validate/finalize are
# exempt: they run (or fail) on the abort path.
assert_downstream_skipped() {
local run_id="$1" name concl bad=0
while IFS=$'\t' read -r name concl; do
case "$name" in
Setup|Finalize) continue ;;
Validate*) continue ;;
Build*|Deploy*)
if [[ "$concl" != "skipped" ]]; then
echo "::error::job '$name' concluded '$concl', expected skipped"
bad=1
fi ;;
esac
done < <(gh run view "$run_id" --json jobs \
-q '.jobs[] | [.name, .conclusion] | @tsv')
[[ "$bad" -eq 0 ]] || fail "downstream jobs were not all skipped on abort"
echo "all build/deploy jobs skipped on abort (run $run_id)"
}
# auto_commits assertion. orchestrate's finalize writes env state to
# trunk through the Contents REST API using CASCADE_STATE_TOKEN, and
# the resulting commit is authored by github-actions[bot]. Walk recent
# trunk commits and require at least one orchestrate state write
# authored by github-actions[bot] whose message matches the literal
# "chore: update state for <ENV> [skip ci]" the generated finalize
# step writes (generator.go).
assert_auto_commit() {
git fetch origin main --quiet
local expected found="" sha author msg
expected="github-actions[bot]"
for sha in $(git rev-list -n 25 origin/main); do
msg=$(git log -1 --format='%s' "$sha")
case "$msg" in
"chore: update state for "*" [skip ci]")
author=$(gh api "repos/$REPO/commits/$sha" \
--jq '.author.login // ""' 2>/dev/null || echo "")
if [[ "$author" == "$expected" ]]; then
found="$sha"
echo "auto-commit $sha by $author: $msg"
break
fi ;;
esac
done
[[ -n "$found" ]] \
|| fail "no '$expected' 'chore: update state for <env> [skip ci]' commit on trunk"
}
_gh_is_transient() {
# $1 = combined gh output. Returns 0 when the failure looks transient.
local out="$1"
if printf '%s' "$out" | grep -qiE 'HTTP 5[0-9][0-9]|HTTP 429|HTTP 401|Bad credentials|was submitted too quickly|secondary rate limit'; then
return 0
fi
if printf '%s' "$out" | grep -qiE 'HTTP 403'; then
if printf '%s' "$out" | grep -qiE 'rate limit|secondary|abuse|too quickly'; then
return 0
fi
fi
return 1
}
gh() {
local attempt=1 max="${GH_RETRY_MAX:-5}" delay="${GH_RETRY_BASE_DELAY:-3}" out rc
while :; do
# errexit-safe capture: never let a failing gh abort before we inspect rc.
out="$(command gh "$@" 2>&1)" && rc=0 || rc=$?
if [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
return 0
fi
if [ "$attempt" -ge "$max" ] || ! _gh_is_transient "$out"; then
if [ "$attempt" -gt 1 ]; then
printf 'gh: giving up after %d attempts (exit %d)\n' "$attempt" "$rc" >&2
fi
printf '%s\n' "$out" >&2
return "$rc"
fi
printf 'gh: transient error on attempt %d/%d, retrying in %ds\n%s\n' "$attempt" "$max" "$delay" "$out" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
HELPERS
echo "BASH_ENV=$RUNNER_TEMP/helpers.sh" >> "$GITHUB_ENV"
echo "helpers written"
# ---------------------------------------------------------------------
# Stage 0: clean slate. Wipe releases/tags and reset manifest state so the
# run is deterministic regardless of prior history.
# ---------------------------------------------------------------------
- name: Reset repository
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
# Clear any hotfix PR a crashed prior run stranded so the single-flight
# gate does not abort this run's hotfix stage.
close_open_hotfix_prs
gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \
| while read -r t; do gh release delete "$t" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag 2>/dev/null || true; done
git fetch --tags --quiet || true
for t in $(git tag -l 'v*' 'rel-*'); do git push origin --delete "$t" 2>/dev/null || true; done
# On the live fleet a concurrent writer can advance main between the
# reset's read and its push, so a plain push is rejected non-fast-forward.
# Retry with bounded, growing backoff: refresh the checkout to the
# current trunk tip and re-run reset so it re-reads main, re-applies the
# reset, and re-pushes. Fail closed if trunk keeps advancing.
reset_pushed=false
for attempt in 1 2 3 4 5; do
if cascade reset --state --push --config .github/manifest.yaml; then
reset_pushed=true
break
fi
echo "reset push attempt ${attempt} rejected; refreshing trunk and retrying"
git fetch origin main --quiet
git reset --hard origin/main >/dev/null
sleep "$((attempt * 5))"
done
if [ "${reset_pushed}" != true ]; then
fail "cascade reset failed after 5 attempts; trunk kept advancing"
fi
git fetch origin main --quiet
# Scrub any abort-path validate sentinel a crashed prior run stranded on
# trunk. Stage 1 drives the happy path and expects a clean validate, so a
# lingering .cascade-validate-fail file would fail it before it can cut a
# draft. Remove and push only when the file is actually present, retrying
# if trunk advances under us.
git reset --hard origin/main >/dev/null
if git cat-file -e "origin/main:.cascade-validate-fail" 2>/dev/null; then
for attempt in 1 2 3 4 5; do
git rm -f --ignore-unmatch .cascade-validate-fail >/dev/null
git commit --no-gpg-sign -m "chore: clear leftover validate sentinel"
git push origin main && break
echo "sentinel scrub push attempt ${attempt} rejected; refreshing trunk"
git fetch origin main --quiet
git reset --hard origin/main >/dev/null
git cat-file -e "origin/main:.cascade-validate-fail" 2>/dev/null || break
sleep "$((attempt * 5))"
done
git fetch origin main --quiet
echo "leftover validate sentinel scrubbed"
else
echo "no leftover validate sentinel present"
fi
# Reset every env branch the suite drives back to the trunk tip so the
# hotfix cherry-pick is deterministic on each re-run. Without this,
# env/staging accumulates prior-run hotfix commits and manifest/app
# churn, diverging from main until the cherry-pick conflicts. The
# force-push uses the GH_TOKEN-authenticated remote (CASCADE_STATE_TOKEN,
# an admin PAT) so it bypasses branch protection on env/staging. Create
# or reset, idempotent and safe to run every time. In-suite git only,
# so no GPG signing.
MAIN_SHA="$(git rev-parse origin/main)"
PUSH_REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
for env in test staging prod; do
git push --force "$PUSH_REMOTE" "${MAIN_SHA}:refs/heads/env/${env}" \
|| fail "failed to reset env/${env} to main"
echo "env/${env} reset to ${MAIN_SHA}"
done
echo "reset complete"
# ---------------------------------------------------------------------
# Stage 1: commit to trunk -> orchestrate runs the validate gate, then
# the inline builds (base, then app via depends_on), then deploy to the
# first env (test), and cuts a draft rc.0. Assert: validate gate ran,
# both inline builds ran in dependency order, draft created, state.test
# set, staging/prod still unset.
# ---------------------------------------------------------------------
- name: Stage 1 - open PR and merge to trunk
id: merge
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
set -euo pipefail
source "$RUNNER_TEMP/helpers.sh"
BRANCH="scenario/src-$(date +%s)-$RANDOM"
git fetch origin main --quiet
git checkout -B "$BRANCH" origin/main
mkdir -p src
echo "// scenario change $(date -u +%FT%TWZ)" >> src/app.txt
git add src/app.txt
git commit --no-gpg-sign -m "feat: scenario suite change"
git push origin "$BRANCH"
gh pr create --base main --head "$BRANCH" \
--title "feat: scenario suite change" \
--body "Automated scenario run; drives orchestrate on merge."
gh pr merge "$BRANCH" --rebase --delete-branch
git fetch origin main --quiet
MERGE_SHA="$(git rev-parse origin/main)"
echo "merge_sha=$MERGE_SHA" >> "$GITHUB_OUTPUT"
echo "merged PR branch=$BRANCH sha=$MERGE_SHA"
- name: Stage 1 - await orchestrate and assert draft
id: stage1
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
# Wait for the orchestrate run triggered by THIS merge, keyed on
# the post-merge HEAD SHA. Never matches a stale or prior run.
MERGE_SHA="${{ steps.merge.outputs.merge_sha }}"
RUN_ID=""
for _ in $(seq 1 15); do
RUN_ID="$(gh run list --workflow=orchestrate.yaml --branch=main \
--json databaseId,headSha,status \
--jq ".[] | select(.headSha==\"$MERGE_SHA\") | .databaseId" | head -n1)"
[ -n "$RUN_ID" ] && break
sleep "$POLL_INTERVAL"
done
[ -n "$RUN_ID" ] || fail "no orchestrate run found for merge sha $MERGE_SHA"
echo "orchestrate_run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
gh run watch "$RUN_ID" --interval 60 --exit-status
refresh_manifest
# The validate gate + base->app dependency ordering are proven
# deterministically by the gen-time job (depends_on skip-gate) and by
# the abort-path stage below (a failed validate skips downstream).
# Here we only assert the env state landed, which already requires the
# whole callback graph to have succeeded in order.
assert_env_set test
assert_env_unset staging
assert_env_unset prod
assert_release_mode draft
assert_rc_tag_present
- name: Register Stage 1 orchestrate run
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.stage1.outputs.orchestrate_run_id }}
expected-conclusion: success
reason: stage1-orchestrate-merge
upload: 'true'
# ---------------------------------------------------------------------
# Stage 2: promote test -> staging. staging is len-2 (prerelease env), so
# the draft flips to a GitHub prerelease. Assert staging now set, prod
# still unset, release is prerelease.
# ---------------------------------------------------------------------
- name: Stage 2 - promote test to staging
id: stage2
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
# Stamp dispatch time so the wait correlates the run this step
# created, never an older promote run that happens to be newest.
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export RESOLVED_RUN_ID_FILE="$RUNNER_TEMP/stage2-run-id"
gh workflow run promote.yaml -f mode=test-to-staging \
|| fail "failed to dispatch promote test-to-staging"
wait_for_workflow promote.yaml "$TS"
echo "promote_run_id=$(cat "$RESOLVED_RUN_ID_FILE")" >> "$GITHUB_OUTPUT"
refresh_manifest
assert_env_set test
assert_env_set staging
assert_env_unset prod
assert_release_mode prerelease
- name: Register Stage 2 promote run
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.stage2.outputs.promote_run_id }}
expected-conclusion: success
reason: stage2-promote-test-to-staging
upload: 'true'
# ---------------------------------------------------------------------
# Stage 3: promote staging -> prod. Crossing into prod is the publish
# boundary: the prerelease is published as the final vX.Y.Z, rc.* tags are
# cleaned. Assert all three envs set and the release is published.
# ---------------------------------------------------------------------
- name: Stage 3 - promote staging to prod
id: stage3
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
# Stamp dispatch time so the wait correlates the run this step
# created, never an older promote run that happens to be newest.
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export RESOLVED_RUN_ID_FILE="$RUNNER_TEMP/stage3-run-id"
gh workflow run promote.yaml -f mode=staging-to-prod \
|| fail "failed to dispatch promote staging-to-prod"
wait_for_workflow promote.yaml "$TS"
echo "promote_run_id=$(cat "$RESOLVED_RUN_ID_FILE")" >> "$GITHUB_OUTPUT"
refresh_manifest
assert_env_set test
assert_env_set staging
assert_env_set prod
assert_release_mode published
assert_final_tag_published
- name: Register Stage 3 promote run
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.stage3.outputs.promote_run_id }}
expected-conclusion: success
reason: stage3-promote-staging-to-prod
upload: 'true'
# ---------------------------------------------------------------------
# auto_commits assertion: orchestrate's finalize writes env state to trunk
# through the Contents REST API using CASCADE_STATE_TOKEN, so on real GitHub
# the state commit is authored by github-actions[bot] with the literal message
# "chore: update state for <ENV> [skip ci]". Assert that exact author plus
# message shape rather than a commit-count heuristic.
# ---------------------------------------------------------------------
- name: Assert auto_commits author and message
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
assert_auto_commit
# ---------------------------------------------------------------------
# validate.abort_path: prove on_failure: abort. validate is an implicit
# dependency of every build/deploy with default on_failure: abort, so a
# failed validate must skip the entire downstream graph and fail the run.
# Inject the failure deterministically by committing the tracked sentinel
# file .cascade-validate-fail to trunk in the same merge that drives the
# orchestrate run (validate.yaml exits 1 when it is present). Because the
# file lands in the merge that triggers orchestrate, the sentinel is in
# place before validate runs. Then assert the run failed and every
# build/deploy job was skipped. Always remove the sentinel afterward so the
# normal path stays green. A committed file is used instead of a repository
# variable so the suite needs only the contents scope it already holds.
# ---------------------------------------------------------------------
- name: Abort path - inject validate failure
id: abort_seed
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
set -euo pipefail
source "$RUNNER_TEMP/helpers.sh"
SENTINEL="abort-$(date +%s)-$RANDOM"
# Drive orchestrate via a trunk change so the full callback graph runs
# under the failing validate gate. The sentinel file is committed in the
# SAME merge, so it is present when validate checks out this commit.
BRANCH="scenario/abort-$(date +%s)-$RANDOM"
git fetch origin main --quiet
git checkout -B "$BRANCH" origin/main
mkdir -p src
echo "// abort-path change $(date -u +%FT%TWZ)" >> src/app.txt
echo "$SENTINEL" > .cascade-validate-fail
git add src/app.txt .cascade-validate-fail
git commit --no-gpg-sign -m "feat: abort-path scenario change"
git push origin "$BRANCH"
gh pr create --base main --head "$BRANCH" \
--title "feat: abort-path scenario change" \
--body "Drives orchestrate with a failing validate gate."
gh pr merge "$BRANCH" --rebase --delete-branch
git fetch origin main --quiet
echo "sentinel=$SENTINEL" >> "$GITHUB_OUTPUT"
echo "abort_sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
- name: Abort path - assert orchestrate aborts
id: abort_assert
if: always() && steps.abort_seed.outcome == 'success'
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
ABORT_SHA="${{ steps.abort_seed.outputs.abort_sha }}"
RUN_ID="$(orchestrate_run_for_sha "$ABORT_SHA")"
[ -n "$RUN_ID" ] || fail "no orchestrate run found for abort sha $ABORT_SHA"
echo "orchestrate_run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
# The run MUST conclude failure (abort), so do not exit-status here.
gh run watch "$RUN_ID" --interval 60 >/dev/null 2>&1 || true
concl=$(gh run view "$RUN_ID" --json conclusion -q '.conclusion')
[ "$concl" = "failure" ] \
|| fail "expected orchestrate conclusion=failure on abort, got '$concl'"
assert_downstream_skipped "$RUN_ID"
echo "abort path proven: run $RUN_ID failed, downstream skipped"
# The abort-path orchestrate run is a registered NEGATIVE: it must
# conclude failure (on_failure: abort short-circuited the graph). Register
# it expected: failure so reconcile treats it as accounted-for, and so a
# build of that run that wrongly SUCCEEDS (the guard regressed) reds the
# gate instead of passing as benign. Runs whenever the run id resolved,
# even if the assert step above failed downstream.
- name: Register abort-path orchestrate run
if: always() && steps.abort_assert.outputs.orchestrate_run_id != ''
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.abort_assert.outputs.orchestrate_run_id }}
expected-conclusion: failure
reason: abort-path-orchestrate-negative
upload: 'true'
- name: Abort path - clear sentinel
if: always()
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
set -uo pipefail
source "$RUNNER_TEMP/helpers.sh" || true
# Remove the committed sentinel from trunk so validate passes again and
# later stages stay green. The abort merge added it; this merge takes it
# back out. Idempotent: a no-op if it is already absent.
git fetch origin main --quiet || true
if git cat-file -e "origin/main:.cascade-validate-fail" 2>/dev/null; then
BRANCH="scenario/abort-clear-$(date +%s)-$RANDOM"
git checkout -B "$BRANCH" origin/main
git rm --quiet .cascade-validate-fail
git commit --no-gpg-sign -m "chore: clear abort-path validate sentinel"
git push origin "$BRANCH"
gh pr create --base main --head "$BRANCH" \
--title "chore: clear abort-path validate sentinel" \
--body "Removes the abort-path validate sentinel so the normal path stays green." \
&& gh pr merge "$BRANCH" --rebase --delete-branch
git fetch origin main --quiet || true
fi
echo "sentinel cleared"
# ---------------------------------------------------------------------
# hotfix.flow: drive cascade-hotfix.yaml end to end against env/staging.
# Staging already records an RC version + SHA from the ladder above, which
# is the seed cascade hotfix finalize requires. Dispatch the hotfix with a
# fresh trunk fix commit targeting staging (NOT the first env, which the
# planner rejects). The clean cherry-pick auto-merges into env/staging;
# finalize then writes the divergence state. Assert the finalize contract:
# - state.staging.ref == "env/staging"
# - state.staging.patches contains the fix SHA
# - state.staging.version matches v*-rc.*.hotfix.1 (RC base)
# - state.staging.sha == the env/staging merge SHA
# - state.staging.base_sha non-empty (trunk anchor)
#
# Precondition: the generated apply step enables auto-merge on the
# cascade-hotfix PR (gh pr merge --auto). The repository must allow
# auto-merge so a clean cherry-pick lands without a human; otherwise the
# PR stays open and the merge-wait below fails loud.
# ---------------------------------------------------------------------
- name: Hotfix - seed trunk fix and dispatch
id: hotfix_seed
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
set -euo pipefail
source "$RUNNER_TEMP/helpers.sh"
# Staging must carry a recorded SHA/version for finalize to anchor to.
refresh_manifest
staging_sha=$(state_field staging '.state.sha')
staging_ver=$(state_field staging '.state.version')
[ -n "$staging_sha" ] || fail "hotfix precondition: state.staging.sha empty"
[ -n "$staging_ver" ] || fail "hotfix precondition: state.staging.version empty"
echo "staging seed: sha=$staging_sha version=$staging_ver"
# Land a fresh fix on trunk; its SHA is the hotfix commit input.
# The fix must cherry-pick CLEANLY onto env/staging so the hotfix
# flow exercises the clean-cherry-pick auto-merge path (the path this
# scenario asserts). Earlier scenario and abort-path steps append to
# src/app.txt and promote those lines onto env/staging; touching the
# same file here would collide on the trailing context and force the
# conflict path. Write to a run-unique file under src/** instead: a
# brand-new path never conflicts, and src/** still drives orchestrate.
BRANCH="scenario/hotfix-$(date +%s)-$RANDOM"
git fetch origin main --quiet
git checkout -B "$BRANCH" origin/main
mkdir -p src
FIX_FILE="src/hotfix-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt"
echo "// hotfix change $(date -u +%FT%TWZ)" > "$FIX_FILE"
git add "$FIX_FILE"
git commit --no-gpg-sign -m "fix: hotfix scenario patch"
git push origin "$BRANCH"
gh pr create --base main --head "$BRANCH" \
--title "fix: hotfix scenario patch" \
--body "Trunk fix to be cherry-picked onto env/staging by the hotfix flow."
gh pr merge "$BRANCH" --rebase --delete-branch
git fetch origin main --quiet
FIX_SHA="$(git rev-parse origin/main)"
echo "fix_sha=$FIX_SHA" >> "$GITHUB_OUTPUT"
# The fix merge to trunk also triggers orchestrate (src/** changed).
# Let it advance test before the hotfix targets staging, so the two
# flows do not contend on trunk state writes.
ORCH_ID="$(orchestrate_run_for_sha "$FIX_SHA")"
echo "seed_orchestrate_run_id=$ORCH_ID" >> "$GITHUB_OUTPUT"
[ -n "$ORCH_ID" ] && gh run watch "$ORCH_ID" --interval 60 --exit-status || true
# Capture the pre-hotfix test/prod SHAs AFTER the seed orchestrate
# settles, so the no-clobber assert compares like-for-like (#206).
# The orchestrate above advances test (and may touch prod) via an
# async [skip ci] trunk write that lands AFTER the run concludes, so
# reading here without settling would capture a stale, mid-flight SHA
# and false-fail the no-clobber check. Poll trunk (same fetch+reset
# +reread pattern the suite uses elsewhere) until both SHAs are
# non-empty and stable across two consecutive reads, then capture.
# This is the true pre-hotfix deployment state: any later drift in
# test/prod is genuinely the hotfix and must still fail loud below.
pre_test_sha=""
pre_prod_sha=""
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
refresh_manifest
cur_test=$(state_field test '.state.sha')
cur_prod=$(state_field prod '.state.sha')
if [ -n "$cur_test" ] && [ -n "$cur_prod" ] \
&& [ "$cur_test" = "$pre_test_sha" ] \
&& [ "$cur_prod" = "$pre_prod_sha" ]; then
break
fi
pre_test_sha="$cur_test"
pre_prod_sha="$cur_prod"
sleep "$POLL_INTERVAL"
done
[ -n "$pre_test_sha" ] || fail "pre-hotfix capture: state.test.sha never settled"
[ -n "$pre_prod_sha" ] || fail "pre-hotfix capture: state.prod.sha never settled"
echo "pre_test_sha=$pre_test_sha" >> "$GITHUB_OUTPUT"
echo "pre_prod_sha=$pre_prod_sha" >> "$GITHUB_OUTPUT"
echo "pre-hotfix non-target state (settled): test=$pre_test_sha prod=$pre_prod_sha"
# Realign env/staging with recorded state immediately before dispatch.
# cascade derives the hotfix cherry-pick base from recorded
# state.staging.sha, and the plan refuses to run when the live env
# branch tip has drifted from it; a finalized hotfix always leaves the
# two equal. Earlier suite stages reset and promote onto env/staging,
# leaving its tip ahead of recorded state, so pin it back to the
# recorded SHA and let plan see tip == state, exactly as a real adopter
# would. The fresh run-unique fix landed past this base, so it is still
# absent from the branch and cherry-picks cleanly. Admin-PAT remote
# bypasses branch protection; in-suite git only, no GPG signing.
refresh_manifest
realign_staging_sha=$(state_field staging '.state.sha')
[ -n "$realign_staging_sha" ] \
|| fail "hotfix precondition: state.staging.sha empty at realign"
PUSH_REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push --force "$PUSH_REMOTE" \
"${realign_staging_sha}:refs/heads/env/staging" \
|| fail "failed to realign env/staging to recorded state"
echo "realigned env/staging -> ${realign_staging_sha}"
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
gh workflow run cascade-hotfix.yaml \
-f commit="$FIX_SHA" -f target_env=staging -f dry_run=false \
|| fail "failed to dispatch cascade-hotfix.yaml"
echo "dispatch_ts=$TS" >> "$GITHUB_OUTPUT"
echo "dispatched hotfix for $FIX_SHA -> staging"
- name: Hotfix - await plan/apply and PR merge
id: hotfix_pr
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
TS="${{ steps.hotfix_seed.outputs.dispatch_ts }}"
# Wait for the dispatched hotfix run (plan + apply) to conclude. Capture
# its run id for the reconcile ledger.
export RESOLVED_RUN_ID_FILE="$RUNNER_TEMP/hotfix-dispatch-run-id"
wait_for_workflow cascade-hotfix.yaml "$TS"
DISPATCH_RUN_ID="$(cat "$RESOLVED_RUN_ID_FILE")"
# The clean cherry-pick opens a cascade-hotfix PR into env/staging and
# enables auto-merge. Poll for that PR to MERGE, then capture its merge
# commit SHA (the env/staging tip finalize records as state.staging.sha).
# Also capture the PR head branch (hotfix/staging/<short>) and head SHA:
# the env-branch-gate run and the pull_request-triggered Cascade Hotfix
# finalize run are both keyed on this PR head, and the suite must
# register BOTH so reconcile accounts for them (the finalize run is the
# previously-ungated fire-and-forget run).
PR=""
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
PR=$(gh pr list --base env/staging --state all --label cascade-hotfix \
--json number,state,mergeCommit,headRefName,headRefOid \
--jq 'sort_by(.number) | last' 2>/dev/null || echo "")
state=$(echo "$PR" | jq -r '.state // ""')
[ "$state" = "MERGED" ] && break
sleep "$POLL_INTERVAL"
done
[ "$(echo "$PR" | jq -r '.state // ""')" = "MERGED" ] \
|| fail "hotfix PR into env/staging did not merge"
MERGE_SHA=$(echo "$PR" | jq -r '.mergeCommit.oid // ""')
[ -n "$MERGE_SHA" ] || fail "merged hotfix PR has no merge commit sha"
HEAD_BRANCH=$(echo "$PR" | jq -r '.headRefName // ""')
HEAD_SHA=$(echo "$PR" | jq -r '.headRefOid // ""')
[ -n "$HEAD_BRANCH" ] || fail "merged hotfix PR has no head branch"
[ -n "$HEAD_SHA" ] || fail "merged hotfix PR has no head sha"
echo "hotfix PR merged into env/staging at $MERGE_SHA (head $HEAD_BRANCH @ $HEAD_SHA)"
# Resolve the env-branch-gate run fired when the PR opened (it posts the
# required status so auto-merge proceeds). Keyed on the PR head branch
# AND resolved by identity: take the earliest pull_request run created at
# or after the hotfix dispatch ($TS), so concurrent fleet load cannot
# latch onto a stale prior run on a recycled hotfix branch.
GATE_RUN_ID=""
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
GATE_RUN_ID=$(gh run list --workflow=env-branch-gate.yaml \
--branch "$HEAD_BRANCH" --event pull_request \
--json databaseId,createdAt 2>/dev/null \
| jq -r --arg since "$TS" \
'[.[] | select(.createdAt >= $since)] | sort_by(.createdAt) | .[0].databaseId // empty' \
2>/dev/null || echo "")
[ -n "$GATE_RUN_ID" ] && break
sleep "$POLL_INTERVAL"
done
[ -n "$GATE_RUN_ID" ] || fail "no env-branch-gate run found for hotfix PR head $HEAD_BRANCH"
# Resolve the pull_request-triggered Cascade Hotfix finalize run: it
# fires when the hotfix PR merges into env/staging and runs the Finalize
# job. This is the run the suite historically NEVER inspected (the
# fire-and-forget gap). Key it on the same PR head branch + the
# pull_request event so it never collides with the workflow_dispatch run
# captured above. Wait for it to conclude and require success.
# Resolved by identity the same way: the earliest pull_request run on the
# PR head created at or after the hotfix dispatch ($TS), never a stale
# prior run on a recycled branch.
FINALIZE_RUN_ID=""
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
FINALIZE_RUN_ID=$(gh run list --workflow=cascade-hotfix.yaml \
--branch "$HEAD_BRANCH" --event pull_request \
--json databaseId,createdAt 2>/dev/null \
| jq -r --arg since "$TS" \
'[.[] | select(.createdAt >= $since)] | sort_by(.createdAt) | .[0].databaseId // empty' \
2>/dev/null || echo "")
[ -n "$FINALIZE_RUN_ID" ] && break
sleep "$POLL_INTERVAL"
done
[ -n "$FINALIZE_RUN_ID" ] \
|| fail "no pull_request Cascade Hotfix finalize run found for head $HEAD_BRANCH"
gh run watch "$FINALIZE_RUN_ID" --interval 60 --exit-status \
|| fail "pull_request Cascade Hotfix finalize run $FINALIZE_RUN_ID did not conclude success"
echo "hotfix finalize (pull_request) run $FINALIZE_RUN_ID concluded success"
# Emit every resolved run id in one grouped redirect (SC2129).
{
echo "dispatch_run_id=$DISPATCH_RUN_ID"
echo "merge_sha=$MERGE_SHA"
echo "head_branch=$HEAD_BRANCH"
echo "head_sha=$HEAD_SHA"
echo "gate_run_id=$GATE_RUN_ID"
echo "finalize_run_id=$FINALIZE_RUN_ID"
} >> "$GITHUB_OUTPUT"
- name: Register hotfix seed orchestrate run
if: steps.hotfix_seed.outputs.seed_orchestrate_run_id != ''
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.hotfix_seed.outputs.seed_orchestrate_run_id }}
expected-conclusion: success
reason: hotfix-seed-orchestrate
upload: 'true'
- name: Register hotfix dispatch (plan/apply) run
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.hotfix_pr.outputs.dispatch_run_id }}
expected-conclusion: success
reason: hotfix-dispatch-plan-apply
upload: 'true'
- name: Register hotfix env-branch-gate run
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.hotfix_pr.outputs.gate_run_id }}
expected-conclusion: success
reason: hotfix-env-branch-gate
upload: 'true'
# The previously-ungated fire-and-forget run: the pull_request-triggered
# Cascade Hotfix Finalize run on hotfix/staging/*. Registering it closes
# the known coverage gap - reconcile now fails the suite if this run
# concludes anything other than success.
- name: Register hotfix finalize (pull_request) run
uses: stablekernel/cascade/.github/actions/register-run@main
with:
run-id: ${{ steps.hotfix_pr.outputs.finalize_run_id }}
expected-conclusion: success
reason: hotfix-finalize-pull-request
upload: 'true'
- name: Hotfix - await finalize and assert state
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
source "$RUNNER_TEMP/helpers.sh"
FIX_SHA="${{ steps.hotfix_seed.outputs.fix_sha }}"
MERGE_SHA="${{ steps.hotfix_pr.outputs.merge_sha }}"
# The PR-close run (context -> build -> deploy -> finalize) writes the
# divergence state to trunk. Poll trunk until state.staging.ref appears.
ref=""
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
refresh_manifest
ref=$(state_field staging '.state.ref')
[ "$ref" = "env/staging" ] && break
sleep "$POLL_INTERVAL"
done
[ "$ref" = "env/staging" ] \
|| fail "expected state.staging.ref=env/staging, got '$ref'"
# patches must contain the trunk fix SHA (full SHA, appended).
patches=$(cascade status env staging --json -c .github/manifest.yaml \
| jq -r '.state.patches[]? // empty')
echo "$patches" | grep -qx "$FIX_SHA" \
|| fail "state.staging.patches must contain fix sha $FIX_SHA; got: $patches"
# version is a nested hotfix segment off the RC base: v*-rc.*.hotfix.1
ver=$(state_field staging '.state.version')
echo "$ver" | grep -Eq '^v.*-rc\..*\.hotfix\.[0-9]+$' \
|| fail "state.staging.version must be an rc hotfix (v*-rc.*.hotfix.N), got '$ver'"
# sha is the env/staging merge tip; base_sha anchors trunk divergence.
sha=$(state_field staging '.state.sha')
[ "$sha" = "$MERGE_SHA" ] \
|| fail "state.staging.sha must equal env/staging merge sha $MERGE_SHA, got '$sha'"
base_sha=$(state_field staging '.state.base_sha')
[ -n "$base_sha" ] \
|| fail "state.staging.base_sha must be a non-empty trunk anchor"
echo "hotfix finalize state OK: ref=$ref version=$ver sha=$sha base_sha=$base_sha"
# No-clobber: hotfix finalize preserves non-target env state (#206).
# The hotfix targeted staging only, so test and prod trunk state must
# be byte-identical to the pre-hotfix capture. The staging finalize
# commit landed above (ref poll), but the state write is one async
# [skip ci] push, so refresh once more before reading the siblings to
# stay stale-read-safe (same pattern the rest of the suite uses).
PRE_TEST_SHA="${{ steps.hotfix_seed.outputs.pre_test_sha }}"
PRE_PROD_SHA="${{ steps.hotfix_seed.outputs.pre_prod_sha }}"
refresh_manifest
post_test_sha=$(state_field test '.state.sha')
post_prod_sha=$(state_field prod '.state.sha')
[ "$post_test_sha" = "$PRE_TEST_SHA" ] \
|| fail "hotfix CLOBBERED state.test.sha: expected '$PRE_TEST_SHA', got '$post_test_sha'"
[ "$post_prod_sha" = "$PRE_PROD_SHA" ] \
|| fail "hotfix CLOBBERED state.prod.sha: expected '$PRE_PROD_SHA', got '$post_prod_sha'"
echo "no-clobber OK: state.test.sha and state.prod.sha unchanged by hotfix (#206)"
# ---------------------------------------------------------------------
# Report-back: write a human-readable summary. The job status itself is
# the badge surface (see README). No token needed for the summary.
# ---------------------------------------------------------------------
- name: Summary
if: always()
run: |
{
echo "## 3env scenario suite"
echo ""
echo "| env | version |"
echo "|-----|---------|"
for e in test staging prod; do
v=$(cascade status env "$e" --json -c .github/manifest.yaml 2>/dev/null \
| jq -r '.version // "-"')
echo "| $e | $v |"
done
echo ""
echo "Result: ${{ job.status }}"
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------
# Reconcile gate: structural coverage backstop. The suite's own asserts above
# still gate each run they remember to wait on; this job is additive. It
# enumerates EVERY run this repo produced since window-start (captured before
# the first dispatch) and fails if any is unaccounted for in the ledger the
# register-run steps uploaded - an unregistered non-success run, or a
# registered run that concluded other than its expected conclusion. That turns
# any fire-and-forget run the suite forgot to gate (historically the
# pull_request Cascade Hotfix Finalize run) into a hard red.
reconcile:
name: Reconcile scenario-window runs
needs: [suite]
if: always()
uses: stablekernel/cascade/.github/workflows/fleet-reconcile.yaml@main
permissions:
contents: read
actions: read
with:
window-start: ${{ needs.suite.outputs.window-start }}
# Artifact mode: each register-run step uploaded a per-job ledger under
# the default cascade-run-ledger-* name; reconcile globs and merges them.
cascade-ref: main