项目文件夹

文件
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1411 行
80 KiB
YAML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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