Files
elizaos--eliza/.github/workflows/cloud-cf-deploy.yml
T
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1411 lines
80 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:-<missing>} environment=${observed_environment:-<missing>}, 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