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
1411 lines
80 KiB
YAML
1411 lines
80 KiB
YAML
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
|