Scenario Suite #46
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 | |
| # Bespoke end-to-end validation driver for the no-env example. The no-env | |
| # (library / CLI) topology declares no environments, so cascade emits no | |
| # deploy lane: orchestrate goes straight from a trunk merge to a minted RC | |
| # prerelease, with no per-environment deploy job in the run. This suite | |
| # exercises that path against the real repository: reset to a clean slate, | |
| # fire orchestrate by committing under src/, assert the RC draft/prerelease | |
| # is minted, and assert that no deploy job ran in that orchestrate run. This | |
| # file is hand-written and is not part of the workflow set that cascade | |
| # produces. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| orchestrate_timeout_minutes: | |
| description: 'Max minutes to wait for the orchestrate run to mint an RC' | |
| type: string | |
| default: '8' | |
| schedule: | |
| # Daily smoke run at 06:30 UTC. | |
| - cron: '30 6 * * *' | |
| permissions: | |
| contents: write | |
| actions: write | |
| pull-requests: write | |
| concurrency: | |
| group: scenario-suite | |
| cancel-in-progress: false | |
| jobs: | |
| scenario: | |
| name: No-env orchestrate scenario | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| 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 }} | |
| env: | |
| MANIFEST_FILE: .github/manifest.yaml | |
| MANIFEST_KEY: ci | |
| TAG_PREFIX: rel- | |
| BOT_NAME: 'github-actions[bot]' | |
| BOT_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com' | |
| steps: | |
| - name: Install gh transient-retry wrapper | |
| run: | | |
| cat > "$RUNNER_TEMP/gh-retry.sh" <<'GHRETRY' | |
| _gh_is_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 | |
| 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 | |
| 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 | |
| } | |
| GHRETRY | |
| echo "BASH_ENV=$RUNNER_TEMP/gh-retry.sh" >> "$GITHUB_ENV" | |
| # 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 CLI | |
| uses: stablekernel/cascade/.github/actions/setup-cli@v0.7.0 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| version: v0.7.0 | |
| - name: Configure git identity | |
| run: | | |
| git config user.name "$BOT_NAME" | |
| git config user.email "$BOT_EMAIL" | |
| # ------------------------------------------------------------------ | |
| # Stage 0 - RESET: wipe every release, tag, and state entry so the run | |
| # always starts from a clean slate. | |
| # ------------------------------------------------------------------ | |
| - name: Reset to clean slate | |
| env: | |
| GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| echo "Resetting releases, tags, and manifest state..." | |
| 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 reads trunk, rewrites manifest state, and pushes the | |
| # result. On the live fleet a concurrent writer can advance main between | |
| # the read and the push, so a plain push is rejected non-fast-forward. | |
| # Retry with bounded, growing backoff: on a rejected push, refresh the | |
| # checkout to the current trunk tip and re-run reset so it re-reads the | |
| # now-current main, re-applies the reset, and re-pushes. Fail closed if | |
| # trunk keeps advancing past the attempt budget. | |
| reset_pushed=false | |
| for attempt in 1 2 3 4 5; do | |
| if cascade reset --state --push; 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 | |
| echo "::error::cascade reset --state --push failed after 5 attempts; trunk kept advancing" | |
| exit 1 | |
| fi | |
| # Re-sync the local checkout to whatever reset pushed to trunk. | |
| git fetch origin "${GITHUB_REF_NAME}" | |
| git reset --hard "origin/${GITHUB_REF_NAME}" | |
| echo "Reset complete." | |
| # ------------------------------------------------------------------ | |
| # Stage 1 - FIRE ORCHESTRATE: open a PR with a src/ change, rebase-merge | |
| # it so the branch commit lands on main carrying a feat: subject, which | |
| # matches the orchestrate path filter and triggers a version bump. | |
| # ------------------------------------------------------------------ | |
| - name: Open and merge src-change PR to fire orchestrate | |
| id: fire | |
| env: | |
| GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "$BOT_NAME" | |
| git config user.email "$BOT_EMAIL" | |
| # Unique branch so re-runs never collide. | |
| STAMP="$(date -u +%Y%m%d%H%M%S)-${GITHUB_RUN_ID}" | |
| BRANCH="scenario/src-${STAMP}" | |
| git fetch origin main --quiet | |
| git checkout -B "$BRANCH" origin/main | |
| # Write under src/ to match the orchestrate path filter. | |
| mkdir -p src | |
| printf 'scenario run %s\n' "$STAMP" > src/scenario.txt | |
| git add src/scenario.txt | |
| # The branch commit message carries the conventional type and, under | |
| # rebase, is preserved as the trunk commit subject. | |
| git commit --no-gpg-sign -m "feat: exercise no-env orchestration ${STAMP}" | |
| git push origin "$BRANCH" | |
| # Open PR and rebase-merge so the branch commit lands on main and the | |
| # push event fires orchestrate. | |
| gh pr create \ | |
| --base main \ | |
| --head "$BRANCH" \ | |
| --title "feat: exercise no-env orchestration ${STAMP}" \ | |
| --body "Automated scenario run; drives orchestrate on merge." | |
| gh pr merge "$BRANCH" --rebase --delete-branch | |
| # Capture the exact merge SHA on trunk - used to key the wait loop. | |
| git fetch origin main --quiet | |
| MERGE_SHA="$(git rev-parse origin/main)" | |
| echo "merge_sha=${MERGE_SHA}" >> "$GITHUB_OUTPUT" | |
| echo "Rebase-merged ${BRANCH} -> main at ${MERGE_SHA}." | |
| - name: Wait for orchestrate run to complete | |
| id: orchestrate_wait | |
| env: | |
| GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} | |
| WAIT_MINUTES: ${{ github.event.inputs.orchestrate_timeout_minutes || '8' }} | |
| run: | | |
| set -euo pipefail | |
| MERGE_SHA="${{ steps.fire.outputs.merge_sha }}" | |
| MAX_ATTEMPTS=$(( WAIT_MINUTES )) # one attempt every 60s | |
| # Locate the orchestrate run triggered by this exact merge commit. | |
| RUN_ID="" | |
| for i in $(seq 1 "$MAX_ATTEMPTS"); do | |
| RUN_ID="$(gh run list \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --workflow orchestrate.yaml \ | |
| --branch main \ | |
| --json databaseId,headSha,status \ | |
| --jq ".[] | select(.headSha==\"${MERGE_SHA}\") | .databaseId" \ | |
| 2>/dev/null | head -n1 || true)" | |
| if [ -n "$RUN_ID" ]; then | |
| echo "Found orchestrate run ${RUN_ID} for ${MERGE_SHA}." | |
| break | |
| fi | |
| echo "attempt ${i}/${MAX_ATTEMPTS}: no orchestrate run for ${MERGE_SHA} yet; waiting..." | |
| sleep 60 | |
| done | |
| if [ -z "$RUN_ID" ]; then | |
| echo "::error::No orchestrate run found for merge SHA ${MERGE_SHA} after ${WAIT_MINUTES}m." | |
| exit 1 | |
| fi | |
| # Surface the resolved run id so the fleet reconcile gate can register | |
| # it. Emitted as soon as it is known, before the poll below, so the | |
| # ledger captures it even if the conclusion poll later fails it. | |
| echo "orchestrate_run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" | |
| # Poll the located run to completion. | |
| attempt=0 | |
| while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do | |
| attempt=$(( attempt + 1 )) | |
| LINE="$(gh run view "$RUN_ID" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --json status,conclusion \ | |
| --jq '"\(.status) \(.conclusion)"' 2>/dev/null || true)" | |
| status="${LINE%% *}" | |
| conclusion="${LINE##* }" | |
| echo "poll ${attempt}/${MAX_ATTEMPTS}: run ${RUN_ID} status='${status}' conclusion='${conclusion}'" | |
| if [ "$status" = "completed" ]; then | |
| if [ "$conclusion" = "success" ]; then | |
| echo "Orchestrate run completed successfully." | |
| exit 0 | |
| fi | |
| echo "::error::Orchestrate run ${RUN_ID} completed with conclusion '${conclusion}' (expected success)." | |
| exit 1 | |
| fi | |
| sleep 60 | |
| done | |
| echo "::error::Timed out after ${WAIT_MINUTES}m waiting for orchestrate run ${RUN_ID} to complete." | |
| exit 1 | |
| # Register the orchestrate run in the fleet ledger so the reconcile gate | |
| # accounts for it. Runs whenever the run id resolved, even if the wait | |
| # step above failed it, so a registered run is never silently dropped. | |
| - name: Register orchestrate run | |
| if: always() && steps.orchestrate_wait.outputs.orchestrate_run_id != '' | |
| uses: stablekernel/cascade/.github/actions/register-run@main | |
| with: | |
| run-id: ${{ steps.orchestrate_wait.outputs.orchestrate_run_id }} | |
| expected-conclusion: success | |
| reason: no-env-direct-prerelease | |
| upload: 'true' | |
| # ------------------------------------------------------------------ | |
| # Stage 1 assertions: an RC draft/prerelease exists, a matching rel-*-rc.* | |
| # tag exists, manifest prerelease state is populated, AND no deploy job | |
| # ran in the orchestrate run (the no-env topology emits no deploy lane). | |
| # ------------------------------------------------------------------ | |
| - name: Assert RC draft minted and no deploy ran | |
| id: stage1 | |
| env: | |
| GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} | |
| ORCHESTRATE_RUN_ID: ${{ steps.orchestrate_wait.outputs.orchestrate_run_id }} | |
| run: | | |
| set -euo pipefail | |
| # Pull the latest trunk so we read the state the orchestrate run wrote. | |
| git fetch origin "${GITHUB_REF_NAME}" | |
| git reset --hard "origin/${GITHUB_REF_NAME}" | |
| # 1) A GitHub release tagged rel-*-rc.* that is a draft or prerelease. | |
| RC_RELEASE="" | |
| for attempt in 1 2; do | |
| RC_RELEASE="$(gh release list \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --limit 50 \ | |
| --json tagName,isDraft,isPrerelease \ | |
| --jq "[.[] | select(.tagName | test(\"^${TAG_PREFIX}.*-rc\\\\.\")) | select(.isDraft or .isPrerelease)] | .[0].tagName // \"\"" 2>/dev/null || true)" | |
| if [ -n "$RC_RELEASE" ]; then | |
| break | |
| fi | |
| echo "RC release not visible yet (attempt ${attempt}); waiting..." | |
| sleep 60 | |
| done | |
| if [ -z "$RC_RELEASE" ]; then | |
| echo "::error::No draft/prerelease release tagged ${TAG_PREFIX}*-rc.* was found." | |
| gh release list --repo "${GITHUB_REPOSITORY}" --limit 50 || true | |
| exit 1 | |
| fi | |
| echo "Observed RC release: ${RC_RELEASE}" | |
| # 2) A matching rel-*-rc.* git tag exists on the remote. | |
| RC_GIT_TAG="$(git ls-remote --tags origin \ | |
| | sed -n 's@.*refs/tags/@@p' \ | |
| | grep -E "^${TAG_PREFIX}.*-rc\." \ | |
| | grep -v '\^{}$' \ | |
| | head -n1 || true)" | |
| if [ -z "$RC_GIT_TAG" ]; then | |
| echo "::error::No remote git tag matching ${TAG_PREFIX}*-rc.* was found." | |
| exit 1 | |
| fi | |
| echo "Observed RC git tag: ${RC_GIT_TAG}" | |
| # 3) Manifest prerelease state is populated. | |
| STATE_VERSION="$(yq eval ".${MANIFEST_KEY}.state.prerelease.version // \"\"" "$MANIFEST_FILE")" | |
| STATE_BY="$(yq eval ".${MANIFEST_KEY}.state.prerelease.committed_by // \"\"" "$MANIFEST_FILE")" | |
| if [ -z "$STATE_VERSION" ] || [ "$STATE_VERSION" = "null" ]; then | |
| echo "::error::ci.state.prerelease.version is empty after orchestrate." | |
| exit 1 | |
| fi | |
| if [ -z "$STATE_BY" ] || [ "$STATE_BY" = "null" ]; then | |
| echo "::error::ci.state.prerelease.committed_by is empty after orchestrate." | |
| exit 1 | |
| fi | |
| echo "Observed prerelease state: version=${STATE_VERSION} committed_by=${STATE_BY}" | |
| # 4) No deploy job ran. The no-env topology declares no environments, | |
| # so the orchestrate run must contain no per-environment deploy job. | |
| # Enumerate the run's jobs and assert none is named like a deploy | |
| # lane (cascade names deploy jobs "deploy-<env>" / "Deploy <env>"). | |
| JOB_NAMES="$(gh run view "$ORCHESTRATE_RUN_ID" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --json jobs \ | |
| --jq '.jobs[].name' 2>/dev/null || true)" | |
| echo "Orchestrate run ${ORCHESTRATE_RUN_ID} jobs:" | |
| printf '%s\n' "$JOB_NAMES" | |
| DEPLOY_JOBS="$(printf '%s\n' "$JOB_NAMES" | grep -iE 'deploy' || true)" | |
| if [ -n "$DEPLOY_JOBS" ]; then | |
| echo "::error::no-env orchestrate run unexpectedly contained a deploy job:" | |
| printf '%s\n' "$DEPLOY_JOBS" | |
| exit 1 | |
| fi | |
| echo "Confirmed: no deploy job ran in the no-env orchestrate run." | |
| { | |
| echo "stage1_release=${RC_RELEASE}" | |
| echo "stage1_tag=${RC_GIT_TAG}" | |
| echo "stage1_version=${STATE_VERSION}" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Stage 1 PASS: RC draft '${RC_RELEASE}' minted, tag '${RC_GIT_TAG}' present, state version '${STATE_VERSION}', no deploy job ran." | |
| - name: Write run summary | |
| if: always() | |
| env: | |
| STAGE1_RELEASE: ${{ steps.stage1.outputs.stage1_release }} | |
| STAGE1_TAG: ${{ steps.stage1.outputs.stage1_tag }} | |
| STAGE1_VERSION: ${{ steps.stage1.outputs.stage1_version }} | |
| run: | | |
| { | |
| echo "## Scenario Suite" | |
| echo "" | |
| echo "| Stage | Release stage | GitHub release | Tag |" | |
| echo "|-------|---------------|----------------|-----|" | |
| echo "| 1 | RC draft / prerelease (no deploy) | ${STAGE1_RELEASE:-n/a} | ${STAGE1_TAG:-n/a} |" | |
| echo "" | |
| if [ -n "${STAGE1_RELEASE:-}" ]; then | |
| echo "**Overall: PASS** - no-env orchestrate minted ${STAGE1_VERSION:-?} as ${STAGE1_RELEASE} with no deploy lane." | |
| else | |
| echo "**Overall: see step logs for the failing assertion.**" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # --------------------------------------------------------------------- | |
| # Reconcile gate: structural coverage backstop. The scenario job's own | |
| # asserts above still gate the orchestrate run it waits on; this job is | |
| # additive. It enumerates EVERY run this repo produced since window-start | |
| # (captured before the first merge) and fails if any is unaccounted for in | |
| # the ledger the register-run step 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 | |
| # into a hard red. | |
| reconcile: | |
| name: Reconcile scenario-window runs | |
| needs: [scenario] | |
| if: always() | |
| uses: stablekernel/cascade/.github/workflows/fleet-reconcile.yaml@main | |
| permissions: | |
| contents: read | |
| actions: read | |
| with: | |
| window-start: ${{ needs.scenario.outputs.window-start }} | |
| # Artifact mode: the register-run step uploaded a per-job ledger under | |
| # the default cascade-run-ledger-* name; reconcile globs and merges them. | |
| cascade-ref: main |