scenario-suite #36
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: {} | |
| 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" |