Skip to content

scenario-suite

scenario-suite #36

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: {}
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: '40'
POLL_INTERVAL: '15'
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 unions to the TOP level,
# never onto a reusable-caller job.
# - 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 a TOP-LEVEL union, not
# per-job. Reusable-caller jobs cannot carry job-level permissions.
idtoken=$(yq '.permissions."id-token" // ""' "$ORCH")
[ "$idtoken" = "write" ] \
|| gfail "expected top-level permissions.id-token=write, got '$idtoken'"
baseperms=$(yq '.jobs."build-base".permissions // "null"' "$ORCH")
[ "$baseperms" = "null" ] \
|| gfail "build-base must NOT carry job-level permissions, got '$baseperms'"
# 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'"
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 "gen-time wiring checks passed"
suite:
name: 3env scenario suite
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- 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.0.0-rc.22
with:
token: ${{ secrets.CASCADE_STATE_TOKEN }}
version: v1.0.0-rc.22
- name: Configure git identity
run: |
git config user.name "cascade-suite[bot]"
git config user.email "cascade-suite@users.noreply.github.com"
# ---------------------------------------------------------------------
# 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; }
# 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.
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"
gh run watch "$run_id" --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 20); do
refresh_manifest
v=$(status_version "$env")
[[ -n "$v" ]] && break
sleep 3
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, so the
# commit is authored by that token's user (GH_TOKEN here is the same
# token, so its login is the deterministic expected author - not
# github-actions[bot], which only labels the local git-commit path used
# by act/gitea). Walk recent trunk commits and require at least one
# orchestrate state write authored by the token user 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=$(gh api user --jq '.login' 2>/dev/null || echo "")
[[ -n "$expected" ]] \
|| fail "could not resolve CASCADE_STATE_TOKEN user login"
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"
}
HELPERS
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"
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
cascade reset --state --push --config .github/manifest.yaml || \
fail "cascade reset failed"
git fetch origin main --quiet
# 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" --squash --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
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 30); 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"
gh run watch "$RUN_ID" --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
# ---------------------------------------------------------------------
# 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
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)"
gh workflow run promote.yaml -f mode=test-to-staging \
|| fail "failed to dispatch promote test-to-staging"
wait_for_workflow promote.yaml "$TS"
refresh_manifest
assert_env_set test
assert_env_set staging
assert_env_unset prod
assert_release_mode prerelease
# ---------------------------------------------------------------------
# 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
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)"
gh workflow run promote.yaml -f mode=staging-to-prod \
|| fail "failed to dispatch promote staging-to-prod"
wait_for_workflow promote.yaml "$TS"
refresh_manifest
assert_env_set test
assert_env_set staging
assert_env_set prod
assert_release_mode published
assert_final_tag_published
# ---------------------------------------------------------------------
# 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 that token's user with the literal message
# "chore: update state for <ENV> [skip ci]". Assert that exact author (the
# token login, resolved at run time) + 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" --squash --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
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"
# The run MUST conclude failure (abort), so do not exit-status here.
gh run watch "$RUN_ID" >/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"
- 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" --squash --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" --squash --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")"
[ -n "$ORCH_ID" ] && gh run watch "$ORCH_ID" --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"
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.
wait_for_workflow cascade-hotfix.yaml "$TS"
# 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).
PR=""
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
PR=$(gh pr list --base env/staging --state all --label cascade-hotfix \
--json number,state,mergeCommit \
--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"
echo "merge_sha=$MERGE_SHA" >> "$GITHUB_OUTPUT"
echo "hotfix PR merged into env/staging at $MERGE_SHA"
- 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"