name: Cloud CF Deploy # Cloudflare deployment for the Eliza Cloud surfaces + Workers API. # # The web frontend builds ONE codebase (packages/app, which mounts the whole # cloud UI via @elizaos/ui/cloud) into TWO Pages projects (two domains, one # build target — packages/cloud/frontend has been deleted): # - `eliza-cloud` -> packages/app (build:web) -> elizacloud.ai apex # The cloud console (lander + dashboard) origin. Steward login + dashboard. # - `eliza-app` -> packages/app (build:web) -> app.elizacloud.ai # The Eliza agent app (chat + views) — the same web UI as `bun run dev`. # This re-consolidates the brief Topology-A apex split: both the apex and the # subdomain now serve packages/app; the only difference is the canonical-origin # env baked into each bundle (apex = elizacloud.ai, subdomain = app.elizacloud.ai). # # Per environment (both projects): # - push to develop -> staging (api-staging.elizacloud.ai; staging.* / app-staging.*) # - push to main -> production (api.elizacloud.ai; elizacloud.ai / app.elizacloud.ai) # - pull_request -> preview (Pages preview branch backed by staging API) # # Required repo secrets: # CLOUDFLARE_API_TOKEN - account-scoped token with Workers + Pages edit perms # CLOUDFLARE_ACCOUNT_ID - matches account_id in packages/cloud/api/wrangler.toml # Provider secrets are published when configured (the Worker calls native # providers directly and uses OpenRouter as the backup): # OPENROUTER_API_KEY, CEREBRAS_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, # GROQ_API_KEY, AI_GATEWAY_API_KEY, FAL_KEY, ELEVENLABS_API_KEY, VAST_API_KEY, VAST_BASE_URL. # Media/voice secrets, likewise published when configured (#13406): # FAL_API_KEY (the video provider registry reads FAL_KEY ?? FAL_API_KEY), # ELIZA_VOICE_CATALOG_SIGNING_KEY_BASE64 + ELIZA_VOICE_CATALOG_PUBLIC_KEY_BASE64 # (Ed25519 keypair for the signed voice-model catalog; the catalog route 503s # while the signing key is unset). # # Source: cloud/.github/workflows/cf-deploy.yml in the original elizaOS/cloud repo. on: push: branches: [main, develop] paths: - "packages/cloud/api/**" - "packages/cloud/shared/**" - "packages/cloud/services/**" - "packages/cloud/sdk/**" - "packages/scripts/cloud/**" - "packages/app/**" - "packages/app-core/**" - "packages/ui/**" - ".github/workflows/cloud-cf-deploy.yml" pull_request: branches: [main, develop] paths: - "packages/cloud/api/**" - "packages/cloud/shared/**" - "packages/cloud/services/**" - "packages/cloud/sdk/**" - "packages/scripts/cloud/**" - "packages/app/**" - "packages/app-core/**" - "packages/ui/**" - ".github/workflows/cloud-cf-deploy.yml" workflow_dispatch: inputs: environment: description: Cloudflare environment to deploy required: true default: staging type: choice options: - staging - production force: # Bypass the deploy freshness guard (#14083). The guard SKIPS a deploy # when this run's SHA is an ancestor of the currently-served build (a # stale zombie run clobbering a newer bundle — #14082). Set force=true # for an INTENTIONAL rollback to an older ref. description: Bypass the freshness guard (intentional rollback to an older ref) required: false default: false type: boolean concurrency: # Canonical deploys serialize as a complete release per environment. Grouping # by ref allowed a feature-branch dispatch and a develop push to interleave # their API, console, and app jobs against the same staging resources. PR # previews remain isolated per PR and cancel superseded preview runs. # The v3 suffix retires the prior per-ref groups without inheriting a wedged # pending run; cancel stale deploys instead of rejecting environment gates. group: cloud-cf-deploy-v3-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || (((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging') }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Default to least privilege. Override per-job where needed. permissions: contents: read jobs: validate-deploy-source: name: Validate Canonical Deploy Source runs-on: ubuntu-latest timeout-minutes: 2 steps: - name: Reject feature refs targeting canonical environments env: EVENT_NAME: ${{ github.event_name }} TARGET_ENVIRONMENT: ${{ inputs.environment }} SOURCE_REF: ${{ github.ref }} run: | if [ "$EVENT_NAME" != "workflow_dispatch" ]; then exit 0 fi if [ "$TARGET_ENVIRONMENT" = "production" ]; then expected_ref="refs/heads/main" else expected_ref="refs/heads/develop" fi if [ "$SOURCE_REF" != "$expected_ref" ]; then echo "::error::Canonical $TARGET_ENVIRONMENT deploys must run from $expected_ref, not $SOURCE_REF. Use the pull-request Pages preview for feature refs." exit 1 fi # --------------------------------------------------------------------------- # Database migrations — the schema gate for every deploy job below (#11208). # # Prod refreshes via a workflow_dispatch of THIS workflow, but migrate-db # used to live only in cloud-deploy-backend.yml — the two were decoupled, so # a refresh shipped new Worker/Pages code against whatever schema prod # happened to have. Every deploy job below now `needs: migrate-db`, which # guarantees migrations run AND succeed before any new code serves traffic: # - no pending migrations -> the journal-based migrator is a no-op that # exits 0, so migration-free deploys still ship; # - failed migration -> migrate-db fails -> every deploy job is # skipped (fail-closed; there is no path that deploys on failure); # - pull_request -> migrate-db is skipped; the Pages preview jobs # explicitly allow that one case (previews are frontend-only builds # pointed at the staging API and run without repo secrets anyway). # --------------------------------------------------------------------------- migrate-db: name: Run Database Migrations needs: validate-deploy-source if: github.event_name != 'pull_request' # GitHub-hosted by DEFAULT so migrations don't inherit the shared # self-hosted deploy fleet's heavy-checkout failure modes (mirrors # cloud-deploy-backend). But the hosted ubuntu-latest pool periodically # backs up for hours (org-wide zombie-freeze), and since every deploy job # `needs: migrate-db`, a starved hosted queue blocks ALL prod deploys even # while the self-hosted robot pool sits idle. CLOUD_CF_MIGRATE_RUNNER_JSON # lets ops fail migrations over to the robot pool (e.g. # ["self-hosted","Linux","X64","hetzner-robot"]) when hosted is jammed; # migrate is a light `bun install` + `db:cloud:migrate`, not the heavy # working-tree materialization that motivated keeping deploys off-hosted. runs-on: ${{ fromJSON(vars.CLOUD_CF_MIGRATE_RUNNER_JSON || '["ubuntu-latest"]') }} # Job-level concurrency groups are repo-wide, so this also serializes # against an explicitly dispatched legacy cloud-deploy-backend migration. concurrency: group: cloud-db-migrate-${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} cancel-in-progress: false environment: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} timeout-minutes: 10 env: MIGRATION_DATABASE_URL: ${{ secrets.DATABASE_URL || secrets.RAILWAY_DATABASE_URL || secrets.NEON_DATABASE_URL }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: "22" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: # Pinned off `canary` like every other job in this workflow (#10839): # the deploy-critical path does not gamble on canary regressions even # on GitHub-hosted runners (the link-phase hang stranded two prod # deploys this week). bun-version: "latest" - name: Install dependencies run: | for attempt in 1 2 3; do if [ "$attempt" -eq 3 ]; then bun install --no-save --ignore-scripts --verbose && exit 0 else bun install --no-save --ignore-scripts && exit 0 fi echo "bun install attempt $attempt failed; retrying in 10s..." sleep 10 done exit 1 - name: Fail fast when database secret is missing if: ${{ env.MIGRATION_DATABASE_URL == '' }} run: | # A missing secret must fail the deploy, not silently skip the # migration — otherwise new code ships against a stale schema # (the exact #11208 landmine, and the pre-2026-05-20 silent-skip # regression in cloud-deploy-backend before it). echo "::error::DATABASE_URL / RAILWAY_DATABASE_URL / NEON_DATABASE_URL secret is missing on this environment. The schema cannot be migrated, so the Worker would deploy against a stale DB." echo "Add the secret in: Settings → Environments → Environment secrets." exit 1 - name: Run migrations env: DATABASE_URL: ${{ env.MIGRATION_DATABASE_URL }} run: | # The migrate entry imports @elizaos/core, whose i18n barrel pulls in the # gitignored generated keyword data. Source-mode (eliza-source) migrate # never builds core, so generate the file first (mirrors core's prebuild). node packages/shared/scripts/generate-keywords.mjs bun run db:cloud:migrate # --------------------------------------------------------------------------- # API Worker # --------------------------------------------------------------------------- deploy-api: name: Deploy API Worker # Scope the job to the same GitHub environment as migrate-db so its # production-environment secrets — notably STEWARD_PLATFORM_KEYS (#11461) — # are visible to the publish-secrets step. Without this the un-scoped job # only saw repo secrets, so `publish_secret` skipped the key and a redeploy # would silently recreate the #11461 tenant-isolation gap (#11937). Mirrors # the proven migrate-db declaration exactly (same protection semantics). environment: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} # A production Worker deploy must never be cancelled mid-flight by a newer # main promote (#11640): the workflow-level group did not protect the # in-flight job, so rapid promotes cancelled every Worker deploy and prod # ran stale code. Job-level concurrency groups are repo-wide, so a per-env # group with cancel-in-progress:false serializes deploys ACROSS runs (queue, # never cancel) — same protection migrate-db already has. concurrency: group: cloud-cf-deploy-api-${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} cancel-in-progress: false # Keep the deploy fleet configurable while Cloud ops converges on the # reliable runner pool. Set CLOUD_CF_DEPLOY_RUNNER_JSON to a JSON runs-on # array such as ["self-hosted","Linux","X64","hetzner-robot"] to use the # Hetzner fleet; otherwise default to GitHub-hosted Ubuntu. runs-on: ${{ fromJSON(vars.CLOUD_CF_DEPLOY_RUNNER_JSON || '["ubuntu-latest"]') }} timeout-minutes: 75 # Schema gate (#11208): the Worker talks to the database, so it only # deploys after migrate-db succeeded for this same commit. A failed or # cancelled migrate-db skips this job — fail-closed. needs: migrate-db if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.migrate-db.result == 'success' }} env: CHECKOUT_DIR: /tmp/eliza-checkout-${{ github.run_id }}-${{ github.run_attempt }}-deploy-api CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS: ${{ vars.CLOUD_CF_CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS || '600' }} steps: - name: Reclaim disk from leaked checkouts of prior runs continue-on-error: true run: | # Cancelled/killed deploy runs never reach the trailing "Clean temp # checkout" step, so their /tmp/eliza-checkout-* trees (a full ~46k-file # monorepo materialization) leak and pile up on this shared self-hosted # box until disk/inodes are exhausted and materialization times out — # freezing prod (elizaOS/eliza#10839). Reclaim leaked trees, but ONLY # ones idle >90 min: a real deploy job runs <~50 min and self-cleans, so # a dir untouched for 90 min is a definitively-dead leak. # CONCURRENCY-SAFE: API, console, and app jobs within one release still # materialize concurrently, and PR previews use independent groups. The # age filter never deletes an actively-written checkout (its mtime stays # fresh), and we also exclude this run's own dirs by name. # UNFAILABLE BY CONSTRUCTION: sibling runs execute this same step at the # same moment, so one sibling's rm -rf can race another's find into # ENOENT (exit 1) — under the job's default `bash -e` (plus the pipefail # this step previously set) that killed whole deploy jobs at step one. # set +e swallows every failure mode, exit 0 guarantees the step, and # continue-on-error guards future edits. set +e set +o pipefail find_stale_checkouts() { timeout 20s find /tmp -maxdepth 1 -type d \ -name 'eliza-checkout-*' \ -mmin +90 \ ! -name "eliza-checkout-${{ github.run_id }}-*" \ 2>/dev/null || true } mapfile -t stale_dirs < <(find_stale_checkouts) stale=${#stale_dirs[@]} moved=0 index=0 for dir in "${stale_dirs[@]}"; do index=$((index + 1)) stale_dir="/tmp/eliza-stale-checkout-${GITHUB_RUN_ID:-run}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-${index}" if mv "$dir" "$stale_dir" 2>/dev/null; then moved=$((moved + 1)) nohup bash -c 'timeout 180s rm -rf "$1" || true' _ "$stale_dir" >/dev/null 2>&1 & fi done echo "Scheduled background cleanup for ${moved}/${stale:-0} stale (idle >90min) leaked checkout dir(s)." df -h /tmp / 2>/dev/null | tail -2 exit 0 - name: Checkout repository timeout-minutes: 20 env: CHECKOUT_TOKEN: ${{ github.token }} working-directory: /tmp run: | set -euo pipefail init_checkout_dir() { mkdir -p "$CHECKOUT_DIR" git init -q "$CHECKOUT_DIR" git -C "$CHECKOUT_DIR" remote set-url origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 2>/dev/null \ || git -C "$CHECKOUT_DIR" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" } init_checkout_dir # Shared, heavily-loaded self-hosted box (wrangler dev + PGlite + the full # Worker suite): a single shallow fetch can stall for minutes or wedge # entirely. Retry with a per-attempt timeout so a stuck fetch aborts and # retries instead of burning the whole step budget (the #10310 stuck-checkout). # http.version=HTTP/1.1: with 3 deploy jobs (+ CodeQL) fetching this # large repo concurrently on one loaded box, HTTP/2 fetches die with # `RPC failed; curl 92 ... stream was not closed cleanly: CANCEL` / # `early EOF` / `fetch-pack: invalid index-pack output` (observed run # 28544777730, both attempts). HTTP/1.1 avoids the h2 stream-reset # path entirely at negligible cost for a single-stream shallow fetch. fetched= for attempt in 1 2 3; do if timeout 420s git -C "$CHECKOUT_DIR" -c protocol.version=2 -c http.version=HTTP/1.1 fetch --force --no-tags --depth=1 origin "$GITHUB_SHA"; then fetched=1 break fi echo "::warning::checkout fetch attempt ${attempt} stalled or failed; retrying" if [ "$attempt" -lt 3 ]; then rm -rf "$CHECKOUT_DIR" sleep 10 init_checkout_dir fi done [ -n "$fetched" ] || { echo "::error::git fetch failed after 3 attempts"; exit 1; } # checkout --force --detach fully materializes the working tree from the # fetched commit; the prior extra `git reset --hard` re-wrote the entire # tree a second time, doubling the slowest part on this large repo. Keep # this bounded, but give the large monorepo enough time on busy deploy # runners and let Git parallelize path materialization when supported. timeout "${CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS}s" \ git -C "$CHECKOUT_DIR" \ -c checkout.workers=8 \ -c checkout.thresholdForParallelism=100 \ checkout --force --detach FETCH_HEAD \ || { echo "::error::git checkout (working-tree materialization) timed out or failed after ${CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS}s"; exit 1; } - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: "22" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: # Pinned off `canary`: canary's link phase hangs on the self-hosted # runners (bun install resolves+extracts in ~2min then stalls past the # 900s cap, ×3 retries), stranding prod deploys — observed on runs # 28552075615 and 28569068598 (the money-integrity wave). A released # bun does not exhibit the hang. See elizaOS/eliza#10839. bun-version: "latest" - name: Install dependencies working-directory: ${{ env.CHECKOUT_DIR }} # The shared self-hosted deploy box can stall or kill a single bun # install mid-flight (contended cache/IO), which surfaced as a # "cancelled" install step failing an otherwise-green deploy while every # sibling deploy job on the same run succeeded. Retry with a per-attempt # timeout so a stuck/killed install aborts and retries instead of failing # the deploy — the same resilience the checkout step already uses (#10310). # The cap must exceed the box's real worst-case SLOW install, not just # catch hangs: healthy installs here run ~190-300s+ (run 28532809562's # first attempt already blew a 300s cap and only survived via retry), # and on 2026-07-01 run 28545551059 failed 3/3 attempts at a 300s cap # while making real progress — wedging prod for hours (#10839). 900s # matches CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS; a true hang still aborts. run: | set -euo pipefail # Cache on LOCAL disk ($HOME), not under $PWD — the checkout dir lives # on the box's /tmp, whose contended/slow filesystem stalls bun's link # phase past the 900s cap and wedges deploys (bun itself logs "Slow # filesystem detected ... consider setting $BUN_INSTALL_CACHE_DIR to a # local folder"). This is the real fix; the earlier canary→latest pin # (#11235) did not address it — the hang recurs on a released bun too # (run 28572046433). Persistent across runs so the cache stays warm. install_cache="${HOME}/.bun-install-cache-deploy" install_timeout="${BUN_INSTALL_TIMEOUT_SECONDS:-2400}" for attempt in 1 2 3; do test -f package.json || { echo "::error::package.json missing from $PWD"; ls -la; exit 1; } mkdir -p "$install_cache" if timeout "${install_timeout}s" bun install --no-save --ignore-scripts --cache-dir "$install_cache"; then break fi if [ "$attempt" -eq 3 ]; then echo "::error::bun install failed after ${attempt} attempts" exit 1 fi echo "::warning::bun install attempt ${attempt} timed out (>${install_timeout}s) or failed; retrying" # Keep the download cache warm for the retry — the timeout bites in # the link phase, not download, and a cold cache makes the retry # SLOWER (more likely to time out again). Nuke it only before the # final attempt so a corrupted cache entry can't fail all three. rm -rf node_modules if [ "$attempt" -eq 2 ]; then rm -rf "$install_cache"; fi done git diff --exit-code -- bun.lock - name: Build linked elizaOS workspaces working-directory: ${{ env.CHECKOUT_DIR }} run: bun run build:core - name: Verify Worker working-directory: ${{ env.CHECKOUT_DIR }} run: bun run --cwd packages/cloud/api typecheck - name: Run Worker e2e # Hard deploy gate (#13618): Worker behavior regressions must fail the # deploy instead of shipping behind a green soft-failed e2e step. working-directory: ${{ env.CHECKOUT_DIR }} run: bun run --cwd packages/cloud/api test:e2e env: # The deploy runner is a shared self-hosted box running wrangler dev, # PGlite, and the full Worker suite. Keep release gating tolerant of # runner load without enabling live external Steward side effects. TEST_REQUEST_TIMEOUT_MS: "60000" RUN_STEWARD_WALLET_E2E: "0" - name: Build working-directory: ${{ env.CHECKOUT_DIR }} run: bun run --cwd packages/cloud/api build - name: Resolve deploy environment id: env working-directory: ${{ env.CHECKOUT_DIR }} run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.environment }}" = "production" ]; then echo "wrangler_args=--env production" >> "$GITHUB_OUTPUT" echo "deploy_environment=production" >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "wrangler_args=--env staging" >> "$GITHUB_OUTPUT" echo "deploy_environment=staging" >> "$GITHUB_OUTPUT" elif [ "${{ github.ref }}" = "refs/heads/main" ]; then echo "wrangler_args=--env production" >> "$GITHUB_OUTPUT" echo "deploy_environment=production" >> "$GITHUB_OUTPUT" else echo "wrangler_args=--env staging" >> "$GITHUB_OUTPUT" echo "deploy_environment=staging" >> "$GITHUB_OUTPUT" fi - name: Check Cloudflare credentials id: cf working-directory: ${{ env.CHECKOUT_DIR }} env: HAS_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} HAS_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }} EVENT_NAME: ${{ github.event_name }} run: | if [ "$HAS_TOKEN" = "true" ] && [ "$HAS_ACCOUNT" = "true" ]; then echo "configured=true" >> "$GITHUB_OUTPUT" else echo "configured=false" >> "$GITHUB_OUTPUT" MSG="CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID not configured. Add them at https://github.com/elizaOS/eliza/settings/secrets/actions." if [ "$EVENT_NAME" = "pull_request" ]; then # PRs from forks legitimately lack access to repo secrets. echo "::warning::$MSG Skipping Worker deploy (PR event)." >> "$GITHUB_STEP_SUMMARY" else echo "::error::$MSG Refusing to silently skip Worker deploy on $EVENT_NAME event." >> "$GITHUB_STEP_SUMMARY" echo "::error::$MSG Refusing to silently skip Worker deploy on $EVENT_NAME event." exit 1 fi fi - name: Publish Worker AI secrets if: steps.cf.outputs.configured == 'true' working-directory: ${{ env.CHECKOUT_DIR }}/packages/cloud/api env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} FAL_KEY: ${{ secrets.FAL_KEY }} # fal key under its alternate name: the video provider registry reads # FAL_KEY ?? FAL_API_KEY, and prod carries it as FAL_API_KEY (#13406). FAL_API_KEY: ${{ secrets.FAL_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} # Ed25519 keypair for the signed voice-model catalog — # /v1/voice-models/catalog 503s while the signing key is unset # (#13406). publish_secret no-ops with a notice while these are # unset, so they publish on every redeploy once ops adds them. ELIZA_VOICE_CATALOG_SIGNING_KEY_BASE64: ${{ secrets.ELIZA_VOICE_CATALOG_SIGNING_KEY_BASE64 }} ELIZA_VOICE_CATALOG_PUBLIC_KEY_BASE64: ${{ secrets.ELIZA_VOICE_CATALOG_PUBLIC_KEY_BASE64 }} VAST_API_KEY: ${{ secrets.VAST_API_KEY }} VAST_BASE_URL: ${{ secrets.VAST_BASE_URL }} # Platform key the Worker forwards as X-Steward-Platform-Key so per-org # sandbox Steward tenants provision instead of collapsing onto the # shared default tenant (#11461). publish_secret no-ops with a notice # while unset, so this is safe before ops adds the GH env secret and # keeps it published on every redeploy once they do. STEWARD_PLATFORM_KEYS: ${{ secrets.STEWARD_PLATFORM_KEYS }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | # The Worker is the gateway: it calls native providers directly and # uses OpenRouter (BYOK) as the backup. At least one provider key (the # OpenRouter backup, or a native key) must be configured. if [ -z "$OPENROUTER_API_KEY" ] && [ -z "$CEREBRAS_API_KEY" ] && [ -z "$OPENAI_API_KEY" ] && [ -z "$ANTHROPIC_API_KEY" ]; then echo "::error::No model provider key configured. Set OPENROUTER_API_KEY (backup) and/or a native key (CEREBRAS/OPENAI/ANTHROPIC) at https://github.com/elizaOS/eliza/settings/secrets/actions." exit 1 fi publish_secret() { local name="$1" local value="${!name:-}" if [ -z "$value" ]; then echo "::notice::$name is not configured; skipping" return 0 fi for attempt in 1 2 3; do if printf '%s' "$value" | bunx wrangler secret put "$name" ${{ steps.env.outputs.wrangler_args }}; then return 0 fi if [ "$attempt" -lt 3 ]; then echo "::warning::wrangler secret put $name failed on attempt $attempt; retrying in 10s" sleep 10 fi done echo "::error::wrangler secret put $name failed after 3 attempts" return 1 } for name in \ OPENROUTER_API_KEY \ OPENAI_API_KEY \ ANTHROPIC_API_KEY \ CEREBRAS_API_KEY \ GROQ_API_KEY \ AI_GATEWAY_API_KEY \ FAL_KEY \ FAL_API_KEY \ ELEVENLABS_API_KEY \ ELIZA_VOICE_CATALOG_SIGNING_KEY_BASE64 \ ELIZA_VOICE_CATALOG_PUBLIC_KEY_BASE64 \ VAST_API_KEY \ VAST_BASE_URL \ STEWARD_PLATFORM_KEYS do publish_secret "$name" done # Freshness guard (#14083): skip a stale Worker deploy about to roll the # API back behind the currently-served `/api/health` commit stamp. # Fail-open on missing/old stamps; force=true bypasses for rollbacks. - name: Deploy freshness guard id: freshness if: steps.cf.outputs.configured == 'true' working-directory: ${{ env.CHECKOUT_DIR }} env: SERVED_URL: ${{ steps.env.outputs.deploy_environment == 'production' && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force == true }} run: | args=(--run-sha "$GITHUB_SHA" --served-url "$SERVED_URL" --served-path /api/health) if [ "$FORCE" = "true" ]; then args+=(--force) fi node packages/scripts/cloud/deploy-freshness-guard-cli.mjs "${args[@]}" - name: Deploy to Cloudflare Workers if: steps.cf.outputs.configured == 'true' && steps.freshness.outputs.should_deploy == 'true' working-directory: ${{ env.CHECKOUT_DIR }}/packages/cloud/api env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} KOKORO_TTS_URL: ${{ vars.ELIZA_VOICE_KOKORO_TTS_URL }} run: | args=(${{ steps.env.outputs.wrangler_args }} --var ELIZA_DEPLOY_COMMIT:"$GITHUB_SHA") if [ -n "${KOKORO_TTS_URL:-}" ]; then args+=(--var KOKORO_TTS_URL:"$KOKORO_TTS_URL") else echo "::notice::ELIZA_VOICE_KOKORO_TTS_URL is not configured; deploying without KOKORO_TTS_URL" fi bunx wrangler deploy "${args[@]}" - name: Verify deployed API commit if: steps.cf.outputs.configured == 'true' && steps.freshness.outputs.should_deploy == 'true' working-directory: ${{ env.CHECKOUT_DIR }} env: API_URL: ${{ steps.env.outputs.deploy_environment == 'production' && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} DEPLOY_ENVIRONMENT: ${{ steps.env.outputs.deploy_environment }} run: | set -euo pipefail expected_commit="$GITHUB_SHA" expected_environment="$DEPLOY_ENVIRONMENT" health_url="${API_URL%/}/api/health" for attempt in 1 2 3 4 5 6; do tmp="$(mktemp)" code="$(curl -sS -o "$tmp" -w '%{http_code}' -m 20 "$health_url" || echo "000")" if [ "$code" = "200" ] && head -c 1 "$tmp" | grep -q '[{[]'; then observed_commit="$(node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(data.commit || '')" "$tmp")" observed_environment="$(node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(data.environment || '')" "$tmp")" if [ "$observed_commit" = "$expected_commit" ] && [ "$observed_environment" = "$expected_environment" ]; then echo "API health serves commit ${observed_commit} for ${observed_environment}." rm -f "$tmp" exit 0 fi echo "API health attempt ${attempt}: commit=${observed_commit:-} environment=${observed_environment:-}, expected commit=${expected_commit} environment=${expected_environment}" >&2 else echo "API health attempt ${attempt}: HTTP ${code} from ${health_url}" >&2 head -c 300 "$tmp" >&2 2>/dev/null || true echo >&2 fi rm -f "$tmp" [ "$attempt" -lt 6 ] && sleep 10 done echo "::error::API health did not serve the deployed commit ${expected_commit} for ${expected_environment}." exit 1 - name: Clean temp checkout if: always() working-directory: /tmp run: | set -uo pipefail if [ -e "$CHECKOUT_DIR" ]; then stale_dir="/tmp/eliza-stale-checkout-${GITHUB_RUN_ID:-run}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$(date +%s)" mv "$CHECKOUT_DIR" "$stale_dir" 2>/dev/null || stale_dir="$CHECKOUT_DIR" nohup bash -c 'timeout 30s rm -rf "$1" || true' _ "$stale_dir" >/dev/null 2>&1 & echo "Scheduled best-effort cleanup for ${stale_dir}." fi # --------------------------------------------------------------------------- # Console frontend (Cloudflare Pages) — packages/app at the elizacloud.ai apex. # The cloud console (lander + dashboard) origin, built from packages/app (which # mounts the cloud UI via @elizaos/ui/cloud). Deploys to the `eliza-cloud` # Pages project. The only difference from the `deploy-app` job below is the # canonical-origin env baked into the bundle (apex vs the app.* subdomain). # --------------------------------------------------------------------------- deploy-console: name: Deploy Console (Pages · elizacloud.ai) # Per-env (or per-PR) job concurrency so a newer main promote queues behind # the in-flight prod Pages deploy instead of cancelling it (#11640); PR # previews keep a per-PR group that DOES dedupe (cancel-in-progress). concurrency: group: cloud-cf-deploy-console-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || (((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging') }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Keep the deploy fleet configurable while Cloud ops converges on the # reliable runner pool. Set CLOUD_CF_DEPLOY_RUNNER_JSON to a JSON runs-on # array such as ["self-hosted","Linux","X64","hetzner-robot"] to use the # Hetzner fleet; otherwise default to GitHub-hosted Ubuntu. Pull request # previews always use GitHub-hosted runners because the shared deploy robot # can fail before any cleanup step while staging downloaded setup actions. runs-on: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || vars.CLOUD_CF_DEPLOY_RUNNER_JSON || '["ubuntu-latest"]') }} timeout-minutes: 75 # Schema gate (#11208): real (non-PR) deploys only run after migrate-db # succeeded, so the frontend never goes live ahead of the schema its API # features expect. PR previews are the ONE allowed skip: migrate-db is # skipped on pull_request, and previews build against the staging API # without repo secrets. `!cancelled()` overrides the implicit success() # so the skipped-dependency preview case can run; everything else is # fail-closed (failed/cancelled migrate-db -> this job is skipped). needs: migrate-db if: ${{ !cancelled() && (needs.migrate-db.result == 'success' || (github.event_name == 'pull_request' && needs.migrate-db.result == 'skipped')) }} env: CHECKOUT_DIR: /tmp/eliza-checkout-${{ github.run_id }}-${{ github.run_attempt }}-deploy-console CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS: ${{ vars.CLOUD_CF_CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS || '600' }} steps: - name: Reclaim disk from leaked checkouts of prior runs continue-on-error: true run: | # Cancelled/killed deploy runs never reach the trailing "Clean temp # checkout" step, so their /tmp/eliza-checkout-* trees (a full ~46k-file # monorepo materialization) leak and pile up on this shared self-hosted # box until disk/inodes are exhausted and materialization times out — # freezing prod (elizaOS/eliza#10839). Reclaim leaked trees, but ONLY # ones idle >90 min: a real deploy job runs <~50 min and self-cleans, so # a dir untouched for 90 min is a definitively-dead leak. # CONCURRENCY-SAFE: API, console, and app jobs within one release still # materialize concurrently, and PR previews use independent groups. The # age filter never deletes an actively-written checkout (its mtime stays # fresh), and we also exclude this run's own dirs by name. # UNFAILABLE BY CONSTRUCTION: sibling runs execute this same step at the # same moment, so one sibling's rm -rf can race another's find into # ENOENT (exit 1) — under the job's default `bash -e` (plus the pipefail # this step previously set) that killed whole deploy jobs at step one. # set +e swallows every failure mode, exit 0 guarantees the step, and # continue-on-error guards future edits. set +e set +o pipefail find_stale_checkouts() { timeout 20s find /tmp -maxdepth 1 -type d \ -name 'eliza-checkout-*' \ -mmin +90 \ ! -name "eliza-checkout-${{ github.run_id }}-*" \ 2>/dev/null || true } mapfile -t stale_dirs < <(find_stale_checkouts) stale=${#stale_dirs[@]} moved=0 index=0 for dir in "${stale_dirs[@]}"; do index=$((index + 1)) stale_dir="/tmp/eliza-stale-checkout-${GITHUB_RUN_ID:-run}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-${index}" if mv "$dir" "$stale_dir" 2>/dev/null; then moved=$((moved + 1)) nohup bash -c 'timeout 180s rm -rf "$1" || true' _ "$stale_dir" >/dev/null 2>&1 & fi done echo "Scheduled background cleanup for ${moved}/${stale:-0} stale (idle >90min) leaked checkout dir(s)." df -h /tmp / 2>/dev/null | tail -2 exit 0 - name: Checkout repository timeout-minutes: 20 env: CHECKOUT_TOKEN: ${{ github.token }} working-directory: /tmp run: | set -euo pipefail init_checkout_dir() { mkdir -p "$CHECKOUT_DIR" git init -q "$CHECKOUT_DIR" git -C "$CHECKOUT_DIR" remote set-url origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 2>/dev/null \ || git -C "$CHECKOUT_DIR" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" } init_checkout_dir # Shared, heavily-loaded self-hosted box (wrangler dev + PGlite + the full # Worker suite): a single shallow fetch can stall for minutes or wedge # entirely. Retry with a per-attempt timeout so a stuck fetch aborts and # retries instead of burning the whole step budget (the #10310 stuck-checkout). # http.version=HTTP/1.1: concurrent fetches of this large repo on the # loaded box die under HTTP/2 (`curl 92 ... CANCEL` / `early EOF` / # `invalid index-pack output`, observed run 28544777730); HTTP/1.1 # avoids the h2 stream-reset path at negligible single-stream cost. fetched= for attempt in 1 2 3; do # Pages builds materialize packages/app + linked UI/core workspaces. # Blobless fetch pushes that cost into checkout as lazy hydration and # has repeatedly timed out on the deploy fleet, so use a normal # shallow fetch here. if timeout 420s git -C "$CHECKOUT_DIR" -c protocol.version=2 -c http.version=HTTP/1.1 fetch --force --no-tags --depth=1 origin "$GITHUB_SHA"; then fetched=1 break fi echo "::warning::checkout fetch attempt ${attempt} stalled or failed; retrying" if [ "$attempt" -lt 3 ]; then rm -rf "$CHECKOUT_DIR" sleep 10 init_checkout_dir fi done [ -n "$fetched" ] || { echo "::error::git fetch failed after 3 attempts"; exit 1; } # checkout --force --detach fully materializes the working tree from the # fetched commit; the prior extra `git reset --hard` re-wrote the entire # tree a second time, doubling the slowest part on this large repo. Keep # this bounded, but give the large monorepo enough time on busy deploy # runners and let Git parallelize path materialization when supported. timeout "${CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS}s" \ git -C "$CHECKOUT_DIR" \ -c checkout.workers=8 \ -c checkout.thresholdForParallelism=100 \ checkout --force --detach FETCH_HEAD \ || { echo "::error::git checkout (working-tree materialization) timed out or failed after ${CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS}s"; exit 1; } - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: "22" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: # Pinned off `canary`: canary's link phase hangs on the self-hosted # runners (bun install resolves+extracts in ~2min then stalls past the # 900s cap, ×3 retries), stranding prod deploys — observed on runs # 28552075615 and 28569068598 (the money-integrity wave). A released # bun does not exhibit the hang. See elizaOS/eliza#10839. bun-version: "latest" - name: Install dependencies working-directory: ${{ env.CHECKOUT_DIR }} # The shared self-hosted deploy box can stall or kill a single bun # install mid-flight (contended cache/IO), which surfaced as a # "cancelled" install step failing an otherwise-green deploy while every # sibling deploy job on the same run succeeded. Retry with a per-attempt # timeout so a stuck/killed install aborts and retries instead of failing # the deploy — the same resilience the checkout step already uses (#10310). # The cap must exceed the box's real worst-case SLOW install, not just # catch hangs: healthy installs here run ~190-300s+ (run 28532809562's # first attempt already blew a 300s cap and only survived via retry), # and on 2026-07-01 run 28545551059 failed 3/3 attempts at a 300s cap # while making real progress — wedging prod for hours (#10839). 900s # matches CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS; a true hang still aborts. run: | set -euo pipefail # Cache on LOCAL disk ($HOME), not under $PWD — the checkout dir lives # on the box's /tmp, whose contended/slow filesystem stalls bun's link # phase past the 900s cap and wedges deploys (bun itself logs "Slow # filesystem detected ... consider setting $BUN_INSTALL_CACHE_DIR to a # local folder"). This is the real fix; the earlier canary→latest pin # (#11235) did not address it — the hang recurs on a released bun too # (run 28572046433). Persistent across runs so the cache stays warm. install_cache="${HOME}/.bun-install-cache-deploy" install_timeout="${BUN_INSTALL_TIMEOUT_SECONDS:-2400}" for attempt in 1 2 3; do test -f package.json || { echo "::error::package.json missing from $PWD"; ls -la; exit 1; } mkdir -p "$install_cache" if timeout "${install_timeout}s" bun install --no-save --ignore-scripts --cache-dir "$install_cache"; then break fi if [ "$attempt" -eq 3 ]; then echo "::error::bun install failed after ${attempt} attempts" exit 1 fi echo "::warning::bun install attempt ${attempt} timed out (>${install_timeout}s) or failed; retrying" # Keep the download cache warm for the retry — the timeout bites in # the link phase, not download, and a cold cache makes the retry # SLOWER (more likely to time out again). Nuke it only before the # final attempt so a corrupted cache entry can't fail all three. rm -rf node_modules if [ "$attempt" -eq 2 ]; then rm -rf "$install_cache"; fi done git diff --exit-code -- bun.lock - name: Build linked elizaOS core workspace working-directory: ${{ env.CHECKOUT_DIR }} run: bun run build:core # The cloud console origin, built from packages/app. The vite build is the # gate (it resolves @elizaos/* to source); typecheck is enforced by the # separate verify workflow, not here. Identical build to deploy-app — only # the canonical-origin env below differs (apex vs app.* subdomain). - name: Build console (packages/app) working-directory: ${{ env.CHECKOUT_DIR }} run: bun run --cwd packages/app build:web env: VITE_API_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} NEXT_PUBLIC_API_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} VITE_ELIZA_CLOUD_BASE: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://www.elizacloud.ai' || 'https://staging.elizacloud.ai' }} # The console's own canonical origin (OG/canonical tags, returnTo base). VITE_APP_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://elizacloud.ai' || 'https://staging.elizacloud.ai' }} NEXT_PUBLIC_APP_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://elizacloud.ai' || 'https://staging.elizacloud.ai' }} # The Eliza agent app the console's "launch / talk to your agent" CTA # sends users to (separate Pages project / subdomain). VITE_ELIZA_APP_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://app.elizacloud.ai' || 'https://app-staging.elizacloud.ai' }} # Staging uses a separate Steward tenant (`elizacloud-staging`) so its # `magicLinkBaseUrl` redirects email callbacks back to staging.elizacloud.ai. # The SPA reads the VITE_* key; keep NEXT_PUBLIC_* for legacy/shared # callers that still run outside Vite's client env exposure. VITE_STEWARD_TENANT_ID: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'elizacloud' || 'elizacloud-staging' }} NEXT_PUBLIC_STEWARD_TENANT_ID: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'elizacloud' || 'elizacloud-staging' }} # Deployment-env signal baked into the SPA bundle. Read by isomorphic # cloud-shared code (e.g. agent flavor catalog) to branch on env. # Vite strips `process.env` for the browser, so the Worker # `ENVIRONMENT` binding is invisible to the SPA — VITE_ENVIRONMENT is # the only thing the bundle can see at runtime. VITE_ENVIRONMENT: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ vars.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID }} # Refuse to deploy a bundle where the bn.js/crypto graph folded into an # eager chunk (the "Class constructor cannot be invoked without 'new'" # Buffer.allocUnsafe module-init crash that blanks every route). This # regression has reached prod multiple times; fail the deploy instead. # HARD GATE: the chunking fold is fixed (#9150) — the `manualChunks` pin # now lives under `rollupOptions.output` (the key Vite reads) and folds # the crypto/wallet/solana graph into one lazy `vendor-crypto` chunk, so a # clean build passes and any future eager-chunk regression fails the deploy. - name: Verify bundle chunk safety working-directory: ${{ env.CHECKOUT_DIR }}/packages/app run: bun run verify:chunks - name: Resolve Pages branch id: pages working-directory: ${{ env.CHECKOUT_DIR }} env: PR_HEAD_REF: ${{ github.head_ref }} EVENT_NAME: ${{ github.event_name }} run: | if [ "$EVENT_NAME" = "pull_request" ]; then echo "branch=$PR_HEAD_REF" >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.environment }}" = "production" ]; then echo "branch=main" >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "branch=develop" >> "$GITHUB_OUTPUT" else echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" fi - name: Check Cloudflare credentials id: cf working-directory: ${{ env.CHECKOUT_DIR }} env: HAS_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} HAS_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }} EVENT_NAME: ${{ github.event_name }} run: | if [ "$HAS_TOKEN" = "true" ] && [ "$HAS_ACCOUNT" = "true" ]; then echo "configured=true" >> "$GITHUB_OUTPUT" else echo "configured=false" >> "$GITHUB_OUTPUT" MSG="CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID not configured. Add them at https://github.com/elizaOS/eliza/settings/secrets/actions." if [ "$EVENT_NAME" = "pull_request" ]; then # PRs from forks legitimately lack access to repo secrets. echo "::warning::$MSG Skipping Pages deploy (PR event)." >> "$GITHUB_STEP_SUMMARY" else echo "::error::$MSG Refusing to silently skip Pages deploy on $EVENT_NAME event." >> "$GITHUB_STEP_SUMMARY" echo "::error::$MSG Refusing to silently skip Pages deploy on $EVENT_NAME event." exit 1 fi fi # Freshness guard (#14083): SKIP this deploy when THIS run's SHA is an # ancestor of the currently-served build's commit — i.e. a stale zombie # run (stuck queued through a freeze) is about to clobber a NEWER bundle # (the #14082 regression: staging fell back to a pre-#13410 ref hours # after newer builds were live). Fail-open: any ambiguous signal deploys; # only a provably-stale run is skipped. workflow_dispatch force=true # bypasses for intentional rollbacks. - name: Deploy freshness guard id: freshness if: steps.cf.outputs.configured == 'true' && github.event_name != 'pull_request' working-directory: ${{ env.CHECKOUT_DIR }} env: SERVED_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://elizacloud.ai' || 'https://staging.elizacloud.ai' }} FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force == true }} run: | args=(--run-sha "$GITHUB_SHA" --served-url "$SERVED_URL") if [ "$FORCE" = "true" ]; then args+=(--force) fi node packages/scripts/cloud/deploy-freshness-guard-cli.mjs "${args[@]}" - name: Deploy to Cloudflare Pages if: steps.cf.outputs.configured == 'true' && (github.event_name == 'pull_request' || steps.freshness.outputs.should_deploy == 'true') working-directory: ${{ env.CHECKOUT_DIR }}/packages/app env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} PAGES_BRANCH: ${{ steps.pages.outputs.branch }} PAGES_PROJECT: eliza-cloud # Do NOT pass a positional `dist` argument: wrangler.toml already sets # `pages_build_output_dir = "./dist"`, and passing both makes wrangler # ignore wrangler.toml — which silently drops the `functions/` upload # (so `_middleware.ts` never runs and `/api/*` falls through to the # SPA's index.html instead of being proxied to the API Worker). run: | for attempt in 1 2 3; do if bunx wrangler pages deploy --project-name="$PAGES_PROJECT" --branch="$PAGES_BRANCH"; then exit 0 fi if [ "$attempt" = "3" ]; then echo "::error::Cloudflare Pages deploy failed after ${attempt} attempts" exit 1 fi echo "::warning::Cloudflare Pages deploy attempt ${attempt} failed; retrying" sleep $((attempt * 20)) done - name: Verify Pages frontend freshness if: steps.cf.outputs.configured == 'true' && github.event_name != 'pull_request' && steps.freshness.outputs.should_deploy == 'true' working-directory: ${{ env.CHECKOUT_DIR }} env: SERVED_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://elizacloud.ai' || 'https://staging.elizacloud.ai' }} run: | node packages/scripts/cloud/verify-pages-frontend-cli.mjs \ --served-url "$SERVED_URL" \ --dist packages/app/dist \ --require-text "Signing in to your agent" \ --require-text "This tab will continue automatically" \ --require-text "Open this agent from Eliza Cloud" # Same-origin checks against the SITE_URL custom domains were removed: those # domains were served by Vercel, which has been decommissioned. We verify the # freshly-deployed Cloudflare API Worker directly instead. - name: Verify API Worker health if: steps.cf.outputs.configured == 'true' && github.event_name != 'pull_request' working-directory: ${{ env.CHECKOUT_DIR }} env: API_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} run: | # Probe an endpoint up to 5 times (transient blips right after a deploy # shouldn't fail the gate). Echoes "PASS"/"FAIL" on the last line. # Diagnostics go to stderr (visible in the job log); only PASS/FAIL is # written to stdout so the caller can capture it. probe_json() { local label="$1" url="$2" expected="$3" local tmp code content_type for attempt in 1 2 3 4 5; do tmp="$(mktemp)" code="$(curl -sS -o "$tmp" -w '%{http_code}' -m 20 "$url" || echo "000")" content_type="$(file -b --mime-type "$tmp" 2>/dev/null || true)" if [ "$code" = "$expected" ] && head -c 1 "$tmp" | grep -q '[{[]'; then echo "$label OK ($code)" >&2 rm -f "$tmp"; echo PASS; return 0 fi echo "$label attempt $attempt: HTTP $code ($content_type): $url" >&2 head -c 300 "$tmp" >&2 2>/dev/null || true; echo >&2 rm -f "$tmp" [ "$attempt" -lt 5 ] && sleep 8 done echo FAIL; return 0 } # Worker liveness is what THIS deploy is responsible for: hard-fail. if [ "$(probe_json 'direct API health' "${API_URL%/}/api/health" 200 | tail -1)" != "PASS" ]; then echo "::error::API health check failed after retries — the deployed Worker is not responding." exit 1 fi # The Steward providers endpoint is DB-backed. A failure here usually # means a downstream database/infra incident, NOT a bad Worker deploy — # so do NOT block the deploy on it (that turns any DB incident into an # undeployable state, exactly when a fix needs to ship). Warn loudly. if [ "$(probe_json 'direct Steward providers' "${API_URL%/}/steward/auth/providers" 200 | tail -1)" != "PASS" ]; then echo "::warning::Steward providers (DB-backed) check failed after retries. The Worker deployed; this points at a database/infra issue — investigate the DB, do not assume the deploy is bad." echo "⚠️ Steward providers (DB-backed) health check failed post-deploy — likely a downstream DB incident, not this deploy." >> "$GITHUB_STEP_SUMMARY" fi - name: Clean temp checkout if: always() working-directory: /tmp run: | set -uo pipefail if [ -e "$CHECKOUT_DIR" ]; then stale_dir="/tmp/eliza-stale-checkout-${GITHUB_RUN_ID:-run}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$(date +%s)" mv "$CHECKOUT_DIR" "$stale_dir" 2>/dev/null || stale_dir="$CHECKOUT_DIR" nohup bash -c 'timeout 30s rm -rf "$1" || true' _ "$stale_dir" >/dev/null 2>&1 & echo "Scheduled best-effort cleanup for ${stale_dir}." fi # --------------------------------------------------------------------------- # App frontend (Cloudflare Pages) — packages/app (the Eliza agent app) at # app.elizacloud.ai. The same web UI as `bun run dev`. Deploys to the # `eliza-app` Pages project (custom domains app.elizacloud.ai / # app-staging.elizacloud.ai map to the production / develop branches in the # Cloudflare Pages project config). # --------------------------------------------------------------------------- deploy-app: name: Deploy App (Pages · app.elizacloud.ai) # Per-env (or per-PR) job concurrency so a newer main promote queues behind # the in-flight prod Pages deploy instead of cancelling it (#11640); PR # previews keep a per-PR group that DOES dedupe (cancel-in-progress). concurrency: group: cloud-cf-deploy-app-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || (((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging') }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Keep the deploy fleet configurable while Cloud ops converges on the # reliable runner pool. Set CLOUD_CF_DEPLOY_RUNNER_JSON to a JSON runs-on # array such as ["self-hosted","Linux","X64","hetzner-robot"] to use the # Hetzner fleet; otherwise default to GitHub-hosted Ubuntu. Pull request # previews always use GitHub-hosted runners because the shared deploy robot # can fail before any cleanup step while staging downloaded setup actions. runs-on: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || vars.CLOUD_CF_DEPLOY_RUNNER_JSON || '["ubuntu-latest"]') }} timeout-minutes: 75 # Schema gate (#11208): same contract as deploy-console — non-PR deploys # require a successful migrate-db; PR previews (migrate-db skipped) still # run; failed/cancelled migrate-db skips this job (fail-closed). needs: migrate-db if: ${{ !cancelled() && (needs.migrate-db.result == 'success' || (github.event_name == 'pull_request' && needs.migrate-db.result == 'skipped')) }} env: CHECKOUT_DIR: /tmp/eliza-checkout-${{ github.run_id }}-${{ github.run_attempt }}-deploy-app CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS: ${{ vars.CLOUD_CF_CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS || '600' }} steps: - name: Reclaim disk from leaked checkouts of prior runs continue-on-error: true run: | # Cancelled/killed deploy runs never reach the trailing "Clean temp # checkout" step, so their /tmp/eliza-checkout-* trees (a full ~46k-file # monorepo materialization) leak and pile up on this shared self-hosted # box until disk/inodes are exhausted and materialization times out — # freezing prod (elizaOS/eliza#10839). Reclaim leaked trees, but ONLY # ones idle >90 min: a real deploy job runs <~50 min and self-cleans, so # a dir untouched for 90 min is a definitively-dead leak. # CONCURRENCY-SAFE: API, console, and app jobs within one release still # materialize concurrently, and PR previews use independent groups. The # age filter never deletes an actively-written checkout (its mtime stays # fresh), and we also exclude this run's own dirs by name. # UNFAILABLE BY CONSTRUCTION: sibling runs execute this same step at the # same moment, so one sibling's rm -rf can race another's find into # ENOENT (exit 1) — under the job's default `bash -e` (plus the pipefail # this step previously set) that killed whole deploy jobs at step one. # set +e swallows every failure mode, exit 0 guarantees the step, and # continue-on-error guards future edits. set +e set +o pipefail find_stale_checkouts() { timeout 20s find /tmp -maxdepth 1 -type d \ -name 'eliza-checkout-*' \ -mmin +90 \ ! -name "eliza-checkout-${{ github.run_id }}-*" \ 2>/dev/null || true } mapfile -t stale_dirs < <(find_stale_checkouts) stale=${#stale_dirs[@]} moved=0 index=0 for dir in "${stale_dirs[@]}"; do index=$((index + 1)) stale_dir="/tmp/eliza-stale-checkout-${GITHUB_RUN_ID:-run}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-${index}" if mv "$dir" "$stale_dir" 2>/dev/null; then moved=$((moved + 1)) nohup bash -c 'timeout 180s rm -rf "$1" || true' _ "$stale_dir" >/dev/null 2>&1 & fi done echo "Scheduled background cleanup for ${moved}/${stale:-0} stale (idle >90min) leaked checkout dir(s)." df -h /tmp / 2>/dev/null | tail -2 exit 0 - name: Checkout repository timeout-minutes: 20 env: CHECKOUT_TOKEN: ${{ github.token }} working-directory: /tmp run: | set -euo pipefail init_checkout_dir() { mkdir -p "$CHECKOUT_DIR" git init -q "$CHECKOUT_DIR" git -C "$CHECKOUT_DIR" remote set-url origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 2>/dev/null \ || git -C "$CHECKOUT_DIR" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" } init_checkout_dir # Shared, heavily-loaded self-hosted box (wrangler dev + PGlite + the full # Worker suite): a single shallow fetch can stall for minutes or wedge # entirely. Retry with a per-attempt timeout so a stuck fetch aborts and # retries instead of burning the whole step budget (the #10310 stuck-checkout). # http.version=HTTP/1.1: concurrent fetches of this large repo on the # loaded box die under HTTP/2 (`curl 92 ... CANCEL` / `early EOF` / # `invalid index-pack output`, observed run 28544777730); HTTP/1.1 # avoids the h2 stream-reset path at negligible single-stream cost. fetched= for attempt in 1 2 3; do # Pages builds materialize packages/app + linked UI/core workspaces. # Blobless fetch pushes that cost into checkout as lazy hydration and # has repeatedly timed out on the deploy fleet, so use a normal # shallow fetch here. if timeout 420s git -C "$CHECKOUT_DIR" -c protocol.version=2 -c http.version=HTTP/1.1 fetch --force --no-tags --depth=1 origin "$GITHUB_SHA"; then fetched=1 break fi echo "::warning::checkout fetch attempt ${attempt} stalled or failed; retrying" if [ "$attempt" -lt 3 ]; then rm -rf "$CHECKOUT_DIR" sleep 10 init_checkout_dir fi done [ -n "$fetched" ] || { echo "::error::git fetch failed after 3 attempts"; exit 1; } # checkout --force --detach fully materializes the working tree from the # fetched commit; the prior extra `git reset --hard` re-wrote the entire # tree a second time, doubling the slowest part on this large repo. Keep # this bounded, but give the large monorepo enough time on busy deploy # runners and let Git parallelize path materialization when supported. timeout "${CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS}s" \ git -C "$CHECKOUT_DIR" \ -c checkout.workers=8 \ -c checkout.thresholdForParallelism=100 \ checkout --force --detach FETCH_HEAD \ || { echo "::error::git checkout (working-tree materialization) timed out or failed after ${CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS}s"; exit 1; } - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: "22" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: # Pinned off `canary`: canary's link phase hangs on the self-hosted # runners (bun install resolves+extracts in ~2min then stalls past the # 900s cap, ×3 retries), stranding prod deploys — observed on runs # 28552075615 and 28569068598 (the money-integrity wave). A released # bun does not exhibit the hang. See elizaOS/eliza#10839. bun-version: "latest" - name: Install dependencies working-directory: ${{ env.CHECKOUT_DIR }} # The shared self-hosted deploy box can stall or kill a single bun # install mid-flight (contended cache/IO), which surfaced as a # "cancelled" install step failing an otherwise-green deploy while every # sibling deploy job on the same run succeeded. Retry with a per-attempt # timeout so a stuck/killed install aborts and retries instead of failing # the deploy — the same resilience the checkout step already uses (#10310). # The cap must exceed the box's real worst-case SLOW install, not just # catch hangs: healthy installs here run ~190-300s+ (run 28532809562's # first attempt already blew a 300s cap and only survived via retry), # and on 2026-07-01 run 28545551059 failed 3/3 attempts at a 300s cap # while making real progress — wedging prod for hours (#10839). 900s # matches CHECKOUT_MATERIALIZE_TIMEOUT_SECONDS; a true hang still aborts. run: | set -euo pipefail # Cache on LOCAL disk ($HOME), not under $PWD — the checkout dir lives # on the box's /tmp, whose contended/slow filesystem stalls bun's link # phase past the 900s cap and wedges deploys (bun itself logs "Slow # filesystem detected ... consider setting $BUN_INSTALL_CACHE_DIR to a # local folder"). This is the real fix; the earlier canary→latest pin # (#11235) did not address it — the hang recurs on a released bun too # (run 28572046433). Persistent across runs so the cache stays warm. install_cache="${HOME}/.bun-install-cache-deploy" install_timeout="${BUN_INSTALL_TIMEOUT_SECONDS:-2400}" for attempt in 1 2 3; do test -f package.json || { echo "::error::package.json missing from $PWD"; ls -la; exit 1; } mkdir -p "$install_cache" if timeout "${install_timeout}s" bun install --no-save --ignore-scripts --cache-dir "$install_cache"; then break fi if [ "$attempt" -eq 3 ]; then echo "::error::bun install failed after ${attempt} attempts" exit 1 fi echo "::warning::bun install attempt ${attempt} timed out (>${install_timeout}s) or failed; retrying" # Keep the download cache warm for the retry — the timeout bites in # the link phase, not download, and a cold cache makes the retry # SLOWER (more likely to time out again). Nuke it only before the # final attempt so a corrupted cache entry can't fail all three. rm -rf node_modules if [ "$attempt" -eq 2 ]; then rm -rf "$install_cache"; fi done git diff --exit-code -- bun.lock - name: Build linked elizaOS core workspace working-directory: ${{ env.CHECKOUT_DIR }} run: bun run build:core # The Eliza agent app. The vite build is the gate (it resolves @elizaos/* # to source); typecheck is enforced by the separate verify workflow. - name: Build app working-directory: ${{ env.CHECKOUT_DIR }} run: bun run --cwd packages/app build:web env: VITE_API_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} NEXT_PUBLIC_API_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://api.elizacloud.ai' || 'https://api-staging.elizacloud.ai' }} VITE_ELIZA_CLOUD_BASE: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://www.elizacloud.ai' || 'https://staging.elizacloud.ai' }} # The app's own canonical origin — its own subdomain, NOT the apex. NEXT_PUBLIC_APP_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://app.elizacloud.ai' || 'https://app-staging.elizacloud.ai' }} # Steward tenant pin (same tenant as the console; one identity across # the .elizacloud.ai cookie zone). # The SPA reads the VITE_* key; keep NEXT_PUBLIC_* for legacy/shared # callers that still run outside Vite's client env exposure. VITE_STEWARD_TENANT_ID: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'elizacloud' || 'elizacloud-staging' }} NEXT_PUBLIC_STEWARD_TENANT_ID: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'elizacloud' || 'elizacloud-staging' }} VITE_ENVIRONMENT: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ vars.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID }} # Refuse to deploy a bundle where the bn.js/crypto graph folded into an # eager chunk (the "Class constructor cannot be invoked without 'new'" # Buffer.allocUnsafe module-init crash that blanks every route). This # regression has reached prod multiple times; fail the deploy instead. # HARD GATE: the chunking fold is fixed (#9150) — the `manualChunks` pin # now lives under `rollupOptions.output` (the key Vite reads) and folds # the crypto/wallet/solana graph into one lazy `vendor-crypto` chunk, so a # clean build passes and any future eager-chunk regression fails the deploy. - name: Verify bundle chunk safety working-directory: ${{ env.CHECKOUT_DIR }}/packages/app run: bun run verify:chunks - name: Resolve Pages branch id: pages working-directory: ${{ env.CHECKOUT_DIR }} env: PR_HEAD_REF: ${{ github.head_ref }} EVENT_NAME: ${{ github.event_name }} run: | if [ "$EVENT_NAME" = "pull_request" ]; then echo "branch=$PR_HEAD_REF" >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.environment }}" = "production" ]; then echo "branch=main" >> "$GITHUB_OUTPUT" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "branch=develop" >> "$GITHUB_OUTPUT" else echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" fi - name: Check Cloudflare credentials id: cf working-directory: ${{ env.CHECKOUT_DIR }} env: HAS_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} HAS_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }} EVENT_NAME: ${{ github.event_name }} run: | if [ "$HAS_TOKEN" = "true" ] && [ "$HAS_ACCOUNT" = "true" ]; then echo "configured=true" >> "$GITHUB_OUTPUT" else echo "configured=false" >> "$GITHUB_OUTPUT" MSG="CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID not configured. Add them at https://github.com/elizaOS/eliza/settings/secrets/actions." if [ "$EVENT_NAME" = "pull_request" ]; then echo "::warning::$MSG Skipping Pages deploy (PR event)." >> "$GITHUB_STEP_SUMMARY" else echo "::error::$MSG Refusing to silently skip Pages deploy on $EVENT_NAME event." >> "$GITHUB_STEP_SUMMARY" echo "::error::$MSG Refusing to silently skip Pages deploy on $EVENT_NAME event." exit 1 fi fi # Self-bootstrap the project so the first deploy on a fresh account works. # Idempotent: a "project already exists" error is expected and ignored. # NOTE: custom domains (app.elizacloud.ai / app-staging.elizacloud.ai) are # attached once via the Cloudflare dashboard / API — see # docs/cloud-into-eliza/CUTOVER-RUNBOOK.md. - name: Ensure eliza-app Pages project exists if: steps.cf.outputs.configured == 'true' working-directory: ${{ env.CHECKOUT_DIR }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: bunx wrangler pages project create eliza-app --production-branch=main 2>/dev/null || true # Freshness guard (#14083): skip a stale zombie run about to clobber a # newer served build. Fail-open on any ambiguous signal; force=true # bypasses for rollbacks. See the console job for the full rationale. - name: Deploy freshness guard id: freshness if: steps.cf.outputs.configured == 'true' && github.event_name != 'pull_request' working-directory: ${{ env.CHECKOUT_DIR }} env: SERVED_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://app.elizacloud.ai' || 'https://app-staging.elizacloud.ai' }} FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force == true }} run: | args=(--run-sha "$GITHUB_SHA" --served-url "$SERVED_URL") if [ "$FORCE" = "true" ]; then args+=(--force) fi node packages/scripts/cloud/deploy-freshness-guard-cli.mjs "${args[@]}" - name: Deploy to Cloudflare Pages if: steps.cf.outputs.configured == 'true' && (github.event_name == 'pull_request' || steps.freshness.outputs.should_deploy == 'true') working-directory: ${{ env.CHECKOUT_DIR }}/packages/app env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} PAGES_BRANCH: ${{ steps.pages.outputs.branch }} PAGES_PROJECT: eliza-app # Do NOT pass a positional `dist` argument: wrangler.toml already sets # `pages_build_output_dir = "./dist"`, and passing both makes wrangler # ignore wrangler.toml — which silently drops the `functions/` upload. run: | for attempt in 1 2 3; do if bunx wrangler pages deploy --project-name="$PAGES_PROJECT" --branch="$PAGES_BRANCH"; then exit 0 fi if [ "$attempt" = "3" ]; then echo "::error::Cloudflare Pages deploy failed after ${attempt} attempts" exit 1 fi echo "::warning::Cloudflare Pages deploy attempt ${attempt} failed; retrying" sleep $((attempt * 20)) done - name: Verify Pages frontend freshness if: steps.cf.outputs.configured == 'true' && github.event_name != 'pull_request' && steps.freshness.outputs.should_deploy == 'true' working-directory: ${{ env.CHECKOUT_DIR }} env: SERVED_URL: ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'https://app.elizacloud.ai' || 'https://app-staging.elizacloud.ai' }} run: | node packages/scripts/cloud/verify-pages-frontend-cli.mjs \ --served-url "$SERVED_URL" \ --dist packages/app/dist \ --require-text "Signing in to your agent" \ --require-text "This tab will continue automatically" \ --require-text "Open this agent from Eliza Cloud" - name: Clean temp checkout if: always() working-directory: /tmp run: | set -uo pipefail if [ -e "$CHECKOUT_DIR" ]; then stale_dir="/tmp/eliza-stale-checkout-${GITHUB_RUN_ID:-run}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$(date +%s)" mv "$CHECKOUT_DIR" "$stale_dir" 2>/dev/null || stale_dir="$CHECKOUT_DIR" nohup bash -c 'timeout 30s rm -rf "$1" || true' _ "$stale_dir" >/dev/null 2>&1 & echo "Scheduled best-effort cleanup for ${stale_dir}." fi # --------------------------------------------------------------------------- # Post-deploy routing verification (regression guard for "staging pointing at # prod CF"). After the Worker + both Pages projects deploy, probe the LIVE # custom domains of the just-deployed environment and assert each is served by # THAT environment -- not the other one leaking through the prod # `*.elizacloud.ai/*` wildcard or a mis-mapped Pages custom domain. This does # NOT gate the deploy (it already happened); it surfaces a misroute as a red # check. The always-on scheduled monitor is .github/workflows/verify-cf-routing.yml. # --------------------------------------------------------------------------- verify-routing: name: Verify ${{ ((github.event_name == 'workflow_dispatch' && inputs.environment == 'production') || github.ref == 'refs/heads/main') && 'production' || 'staging' }} routing needs: [deploy-api, deploy-console, deploy-app] # Custom domains only exist for the real environments -- PR previews deploy to # a preview branch alias, so skip them. Require all three deploys to have # landed (the beacon is served by the API Worker; the domains by the two Pages # projects), else there is nothing trustworthy to probe. if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.deploy-api.result == 'success' && needs.deploy-console.result == 'success' && needs.deploy-app.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: false show-progress: false - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: "24" - name: Resolve deployed environment id: env run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.environment }}" = "production" ]; then env=production elif [ "${{ github.ref }}" = "refs/heads/main" ]; then env=production else env=staging fi echo "environment=$env" >> "$GITHUB_OUTPUT" - name: Verify the just-deployed environment serves itself # This run built AND deployed the beacon-carrying Worker for this env, so # require the beacon: a beaconless answer means the deploy that was # supposed to carry it did not take. A genuine cross-wire is always fatal. run: | node packages/scripts/cloud/verify-environment-routing-cli.mjs \ --environment "${{ steps.env.outputs.environment }}" \ --require-beacon