scenario-suite #220
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |