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

551 lines
30 KiB
YAML

name: Deploy Eliza Provisioning Worker
# SSHes to the Hetzner provisioning host and deploys one immutable commit,
# installs the systemd unit shipped at packages/scripts/cloud/admin/eliza-provisioning-worker.service,
# and restarts the eliza-provisioning-worker daemon.
#
# First-time setup:
# HCLOUD_TOKEN=... bun run packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs
on:
push:
branches: [develop, main]
paths:
- '.github/workflows/deploy-eliza-provisioning-worker.yml'
- 'packages/scripts/cloud/admin/daemons/provisioning-worker.ts'
- 'packages/scripts/cloud/admin/daemons/agent-router.ts'
- 'packages/scripts/cloud/admin/eliza-provisioning-worker.service'
- 'packages/scripts/cloud/admin/eliza-agent-router.service'
- 'packages/scripts/cloud/admin/ensure-generated-keywords.sh'
- 'packages/cloud/shared/src/lib/services/provisioning-jobs.ts'
- 'packages/cloud/shared/src/lib/services/docker-sandbox-provider.ts'
- 'packages/cloud/shared/src/lib/services/eliza-sandbox.ts'
- 'packages/cloud/shared/src/db/repositories/agent-sandboxes.ts'
- 'packages/cloud/shared/**'
- 'packages/cloud/sdk/**'
- 'packages/shared/**'
- 'packages/core/**'
- 'plugins/plugin-sql/**'
workflow_dispatch:
inputs:
environment:
description: 'Target environment (host + secrets resolved from this)'
required: true
default: 'staging'
type: choice
options:
- staging
- production
deployment_sha:
description: 'Optional exact repository commit for protected staging acceptance'
required: false
type: string
# Per-env concurrency: a develop push (staging) and a main push (production)
# can deploy in parallel without one cancelling the other.
concurrency:
group: deploy-eliza-provisioning-worker-${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }}
cancel-in-progress: false
# Default to least privilege. Override per-job where needed.
permissions:
contents: read
jobs:
determine-env:
name: Determine environment
runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }}
outputs:
environment: ${{ steps.env.outputs.environment }}
branch: ${{ steps.env.outputs.branch }}
deployment_sha: ${{ steps.snapshot.outputs.deployment_sha }}
steps:
- id: env
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
env="${{ github.event.inputs.environment }}"
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
env="production"
else
env="staging"
fi
branch=$([ "$env" = "production" ] && echo "main" || echo "develop")
echo "environment=$env" >> "$GITHUB_OUTPUT"
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "Resolved: environment=$env branch=$branch"
- id: snapshot
env:
BRANCH: ${{ steps.env.outputs.branch }}
PUSH_SHA: ${{ github.sha }}
REQUESTED_SHA: ${{ github.event.inputs.deployment_sha }}
TARGET_ENVIRONMENT: ${{ steps.env.outputs.environment }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
deployment_sha="$PUSH_SHA"
elif [ -n "$REQUESTED_SHA" ]; then
[ "$TARGET_ENVIRONMENT" = "staging" ] || {
echo "::error::An exact deployment_sha is permitted only for protected staging deployments"
exit 1
}
[[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "::error::deployment_sha must be a lowercase 40-hex commit"
exit 1
}
verify_dir=$(mktemp -d)
trap 'rm -rf "$verify_dir"' EXIT
git -C "$verify_dir" init --quiet
git -C "$verify_dir" fetch --quiet --no-tags --depth=1 \
"https://github.com/${GITHUB_REPOSITORY}.git" "$REQUESTED_SHA"
deployment_sha=$(git -C "$verify_dir" rev-parse FETCH_HEAD)
[ "$deployment_sha" = "$REQUESTED_SHA" ] || {
echo "::error::Fetched commit does not match requested deployment_sha"
exit 1
}
else
deployment_sha=$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/heads/$BRANCH" | awk 'NR == 1 { print $1 }')
fi
[[ "$deployment_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Could not resolve immutable deployment SHA for $BRANCH"; exit 1; }
echo "deployment_sha=$deployment_sha" >> "$GITHUB_OUTPUT"
echo "Resolved immutable deployment snapshot: $deployment_sha"
deploy:
name: Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }})
runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }}
needs: determine-env
environment: ${{ needs.determine-env.outputs.environment }}
timeout-minutes: 15
env:
SYSTEMD_UNIT: eliza-provisioning-worker.service
DEPLOY_HOST: ${{ secrets.ELIZA_PROVISIONING_HOST }}
DEPLOY_SSH_KEY: ${{ secrets.ELIZA_PROVISIONING_SSH_KEY }}
DEPLOY_BRANCH: ${{ needs.determine-env.outputs.branch }}
DEPLOY_SHA: ${{ needs.determine-env.outputs.deployment_sha }}
# Headscale wiring for the provisioning worker (the CONSUMER of the
# control plane). These are reconciled into /opt/eliza/cloud/.env.local
# on every deploy so the box can never silently drift to a stale,
# hand-edited headscale URL/key again. Reuse the canonical GH names
# already established by arm-headscale-control-plane.yml.
HEADSCALE_API_URL: ${{ vars.HEADSCALE_API_URL }}
HEADSCALE_PUBLIC_URL: ${{ vars.HEADSCALE_PUBLIC_URL }}
HEADSCALE_API_KEY: ${{ secrets.HEADSCALE_API_KEY }}
# Base64 private key used by the daemon to reach dedicated-agent nodes.
# Keep this in the protected environment instead of relying on a
# hand-copied file that can drift or disappear when the CP host is rebuilt.
CONTAINERS_SSH_KEY: ${{ secrets.CONTAINERS_SSH_KEY }}
# Sandbox-registry Redis (TCP redis:// proxy, sandbox-reachable — never a
# *.railway.internal host) so provisioned sandboxes self-register and
# Discord/Telegram inbound routing works (#8621 / #8756). Reconciled into
# /opt/eliza/cloud/.env.local the same way as HEADSCALE_*; when the secret
# is unset the reconcile loop skips it and preserves any hand-set value,
# so this is a no-op until the operator sets the GH secret.
SANDBOX_REGISTRY_REDIS_URL: ${{ secrets.SANDBOX_REGISTRY_REDIS_URL }}
# Cloud database DSN for the provisioning worker + agent router. Same
# fallback chain as the migrator jobs (cloud-cf-deploy.yml /
# cloud-deploy-backend.yml) so the CP daemon always resolves the exact
# DB the Worker schema is migrated against. Before this, DATABASE_URL
# only lived hand-edited on the box: when staging moved Neon → Railway
# the CP silently kept polling the abandoned Neon DB for 3 weeks
# (#15160). Reconciled into /opt/eliza/cloud/.env.local with the same
# skip-when-unset semantics as HEADSCALE_* — an environment without any
# of these secrets keeps its current hand-set value.
DATABASE_URL: ${{ secrets.DATABASE_URL || secrets.RAILWAY_DATABASE_URL || secrets.NEON_DATABASE_URL }}
# Field-encryption root secret used to unwrap per-org DEKs for encrypted
# agent environment variables. Must match the Cloudflare Worker secret:
# the Worker writes encrypted env vars, and this provisioning worker
# decrypts them while creating the agent container (#15385). Skip-empty
# reconcile semantics below preserve hand-set values until the secret is
# wired for an environment.
SECRETS_MASTER_KEY: ${{ secrets.SECRETS_MASTER_KEY }}
# Operator pin for the canonical managed-agent image
# (containersEnv.defaultAgentImageOverride; falls back to
# ghcr.io/elizaos/eliza:stable when unset everywhere). This pin decides
# which image every NEW provision and warm-pool replenish boots — it is
# exactly the knob the #15228 crash-loop rollout needed. Before this it
# lived ONLY hand-set on the CP box (the same drift class as
# DATABASE_URL/#15160, tracked in the #15401 ledger): a CP re-arm lost
# it silently. Set the GitHub environment VARIABLE (not secret — an
# image ref is auditable config) to roll the fleet pin via deploy;
# leave it unset to preserve whatever is hand-set on the box.
ELIZA_AGENT_IMAGE: ${{ vars.ELIZA_AGENT_IMAGE }}
# The edge Worker forwards the original per-agent host to this daemon.
# Keep the daemon's parser on the same environment-specific suffix or a
# staging host (`<uuid>.staging.elizacloud.ai`) is rejected as malformed.
ELIZA_CLOUD_AGENT_BASE_DOMAIN: ${{ needs.determine-env.outputs.environment == 'production' && 'elizacloud.ai' || 'staging.elizacloud.ai' }}
steps:
- name: Check deploy configuration
id: deploy_config
run: |
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::warning::Missing ELIZA_PROVISIONING_HOST or ELIZA_PROVISIONING_SSH_KEY; skipping provisioning-worker deploy."
{
echo "### Provisioning worker deploy skipped"
echo ""
echo "Missing \`ELIZA_PROVISIONING_HOST\` or \`ELIZA_PROVISIONING_SSH_KEY\` for GitHub environment \`production\`."
echo ""
echo "Bootstrap the Hetzner host and secrets with:"
echo ""
echo "\`\`\`bash"
echo "HCLOUD_TOKEN=... bun run packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::error::Manual deployment requires ELIZA_PROVISIONING_HOST and ELIZA_PROVISIONING_SSH_KEY."
exit 1
fi
exit 0
fi
echo "configured=true" >> "$GITHUB_OUTPUT"
- name: Ensure host prereqs (Node 24, swap, bunx symlink)
if: steps.deploy_config.outputs.configured == 'true'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2
with:
host: ${{ env.DEPLOY_HOST }}
username: deploy
key: ${{ env.DEPLOY_SSH_KEY }}
command_timeout: 5m
script: |
set -euo pipefail
# Idempotent safety net for hosts provisioned BEFORE the cloud-init
# template that pre-installs these. Each block is a no-op if already
# set up — runs on every deploy so any host drift self-heals.
# Node 24: the systemd unit calls `tsx` which spawns a Node process.
# New cloud-init template installs it via NodeSource; older hosts
# (eliza-staging-1 from the first TF apply) lack it.
if ! command -v node >/dev/null 2>&1; then
echo "[host-prereqs] installing Node 24"
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -
sudo apt-get install -y nodejs
fi
# Swap: smaller shapes (cpx22, cax11) OOM during `turbo run build`.
# 8 GB swap once and forever; harmless on bigger shapes.
if [ ! -f /swapfile ]; then
echo "[host-prereqs] adding 8GB swap"
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab >/dev/null
fi
# bunx symlink: the canary `bun.sh/install` tarball stopped shipping
# bunx as a separate binary at some point; build scripts that call
# `bunx tsc` etc. then fail with `bun: command not found: bunx`.
# `bunx` is just `bun` reading argv[0] — a symlink is sufficient.
if [ ! -e /home/deploy/.bun/bin/bunx ] && [ -e /home/deploy/.bun/bin/bun ]; then
echo "[host-prereqs] creating bunx symlink"
ln -sf bun /home/deploy/.bun/bin/bunx
fi
- name: Deploy and restart worker
if: steps.deploy_config.outputs.configured == 'true'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2
with:
host: ${{ env.DEPLOY_HOST }}
username: deploy
key: ${{ env.DEPLOY_SSH_KEY }}
command_timeout: 10m
envs: DEPLOY_BRANCH,DEPLOY_SHA,SYSTEMD_UNIT,HEADSCALE_API_URL,HEADSCALE_PUBLIC_URL,HEADSCALE_API_KEY,CONTAINERS_SSH_KEY,SANDBOX_REGISTRY_REDIS_URL,DATABASE_URL,SECRETS_MASTER_KEY,ELIZA_AGENT_IMAGE,ELIZA_CLOUD_AGENT_BASE_DOMAIN
script: |
set -euo pipefail
echo "=== Deploying eliza-provisioning-worker (branch: $DEPLOY_BRANCH sha: $DEPLOY_SHA) ==="
cd /opt/eliza
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=safe.directory
export GIT_CONFIG_VALUE_0=/opt/eliza
rm -f .git/index.lock
# A prior operator or deploy may have run git/install/build as
# root, leaving either Git metadata or tracked source files owned
# by root. Git then cannot reset or check out the immutable target
# as `deploy`. The checkout is exclusively managed by this service,
# so normalize the whole tree before any mutating Git command.
sudo chown -R deploy:deploy /opt/eliza
# Discard local drift, preserve env files. Quote globs so the
# remote shell doesn't expand them before git sees the pattern,
# and fail loud if clean errors out — silently swallowing it is
# what let stale build artifacts survive and block checkout below.
git reset --hard HEAD
git clean -fdx \
-e .env \
-e .env.local \
-e '.env.*' \
-e cloud/.env.local \
-e 'cloud/.env.*' \
-e node_modules
# Build artifacts (*.d.ts / *.d.ts.map) sometimes land next to
# sources from a prior deploy; if the new HEAD tracks the same
# paths, `git checkout -B` aborts with "untracked working tree
# files would be overwritten". Sweep them before checkout.
find packages -type f \( -name '*.d.ts' -o -name '*.d.ts.map' \) \
! -path '*/node_modules/*' -delete
# bun.lock became tracked at 4ae092dcda; the host's previous deploy
# may have left an untracked copy from `bun install` that pre-dates
# the tracking change. `git clean -fdx` *should* remove it but
# occasionally races with bun's lockfile rewrite, so force a delete
# before the fetch + branch-checkout below.
rm -f bun.lock
# Use a refspec so a single-branch / shallow clone still creates
# the remote-tracking ref for $DEPLOY_BRANCH on first deploy.
#
# --no-recurse-submodules on both fetch + checkout: the provisioning
# worker daemon and agent router are pure Node/tsx code and never
# touch the llama.cpp / opencode submodules. If the
# deploy host has `fetch.recurseSubmodules=on-demand` (the default
# when submodules are present), recursing through a superproject
# commit whose submodule SHA was never pushed to the fork kills the
# whole deploy with `upload-pack: not our ref`. Skip submodule
# traversal entirely on this path.
git -c fetch.recurseSubmodules=no fetch --no-recurse-submodules origin "$DEPLOY_SHA"
git -c submodule.recurse=false checkout --no-recurse-submodules \
-B "$DEPLOY_BRANCH" "$DEPLOY_SHA"
test "$(git rev-parse HEAD)" = "$DEPLOY_SHA"
# The `rm -f bun.lock` above deletes a TRACKED file; when bun.lock
# is identical between the previous HEAD and the new tip, checkout
# carries that deletion over as a local change and `bun install`
# then resolves the whole tree fresh from package.json ranges —
# newest-in-range @ai-sdk/* against the overridden provider-utils
# pin took the worker down at boot (`does not provide an export
# named 'secureJsonParse'`). Always restore the tracked lockfile so
# installs are lockfile-pinned and deterministic.
git checkout "$DEPLOY_SHA" -- bun.lock
# git clean removes these intentionally ignored modules. Generate
# and assert them before install/build, while the healthy daemons
# are still running, so generator failure cannot cause downtime.
bash packages/scripts/cloud/admin/ensure-generated-keywords.sh
# Install the canary (Rust) Bun on first install.
if ! command -v bun >/dev/null 2>&1; then
curl -fsSL https://bun.sh/install | bash -s "canary"
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
fi
cd /opt/eliza
for attempt in 1 2 3; do
if bun install --no-save --ignore-scripts; then
break
fi
if [ "$attempt" -eq 3 ]; then exit 1; fi
echo "bun install attempt $attempt failed; retrying in 10s..."
sleep 10
done
# The daemons run under Node/tsx rather than Bun. Node resolves
# linked workspace packages through their built `node` exports, so
# refresh linked package dist files after installing dependencies.
#
# The `git clean -fdx` above wipes `.turbo`, so every deploy does a
# cold `build:core` (0 cached). Building the full graph includes
# `@elizaos/plugin-local-inference`, whose tsc/bundler step exceeds
# V8's default ~2 GB old-space and aborts the deploy with
# `FATAL ERROR: ... JavaScript heap out of memory` (exit 134). Raise
# the heap ceiling to match the repo's typecheck lane (8 GB).
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=8192"
bun run build:core
mkdir -p plugins/plugin-sql/node_modules/@elizaos
rm -rf plugins/plugin-sql/node_modules/@elizaos/core
ln -s ../../../../packages/core plugins/plugin-sql/node_modules/@elizaos/core
bun run --cwd plugins/plugin-sql build
# Defensive workspace symlinks: `bun install` should create these
# automatically for any workspace package the root or a sibling
# depends on, but the daemon (in packages/scripts/, which has NO
# package.json and so is invisible to bun's workspace graph)
# imports `@elizaos/cloud-shared` at runtime. On a fresh host the
# symlink was missing — daemon then crashes with
# `Cannot find package '@elizaos/cloud-shared'`. Recreate it here
# idempotently after `bun install` is done.
mkdir -p node_modules/@elizaos
ln -sfn ../../packages/cloud/shared node_modules/@elizaos/cloud-shared
# Install/refresh both systemd units (provisioning worker + agent router).
sudo install -m 0644 \
packages/scripts/cloud/admin/eliza-provisioning-worker.service \
"/etc/systemd/system/$SYSTEMD_UNIT"
sudo install -m 0644 \
packages/scripts/cloud/admin/eliza-agent-router.service \
/etc/systemd/system/eliza-agent-router.service
sudo systemctl daemon-reload
sudo systemctl enable "$SYSTEMD_UNIT" eliza-agent-router.service
# Reconcile control-plane secrets (HEADSCALE_API_URL /
# HEADSCALE_PUBLIC_URL / HEADSCALE_API_KEY /
# SANDBOX_REGISTRY_REDIS_URL / DATABASE_URL / SECRETS_MASTER_KEY)
# into the systemd EnvironmentFile so CI is the single source of
# truth and the box self-heals on every deploy. The daemon +
# cloud-shared read these straight from process.env
# (headscale-client.ts, headscale-integration.ts,
# docker-sandbox-provider.ts, the DB client, and encrypted
# environment-variable decrypt), fed by
# EnvironmentFile=/opt/eliza/cloud/.env.local. The
# deploy's `git clean -e cloud/.env.local` preserves that file
# verbatim, so before this these keys were only ever hand-edited on
# the box and could drift to a stale headscale (the CP-box outage)
# or a stale database (#15160: the CP polled an abandoned Neon DB
# for 3 weeks after staging moved to Railway). Only rewrite a key
# when the CI value is non-empty — an unset GH var/secret must
# never blank a working box (a blank HEADSCALE_API_KEY makes
# docker-sandbox-provider throw "HEADSCALE_API_KEY is not
# configured" when a route is required).
ENV_FILE=/opt/eliza/cloud/.env.local
sudo touch "$ENV_FILE"
# Pin this control-plane daemon to the AGENT lane. Unset, a single
# daemon claims ALL job types (agent + apps); it would claim
# APP_DEPLOY / CONTAINER_* jobs it can't run (no apps backend wired
# here) and burn them down through retries. The dedicated apps
# daemon (apps-provisioning-worker.ts) owns the apps lane. Reconciled
# here so CI is the single source of truth and the box self-heals on
# every deploy (same mechanism as the HEADSCALE_* keys below). This
# value is constant — not a GH var — so it's reconciled directly,
# never blanked.
sudo sed -i "/^PROVISIONING_JOB_LANES=/d" "$ENV_FILE"
printf 'PROVISIONING_JOB_LANES=%s\n' "agent" | sudo tee -a "$ENV_FILE" >/dev/null
# Headscale is the required dedicated-agent ingress. Bridge-host
# fallback was a legacy escape hatch; if it remains hand-set on the
# box, docker-sandbox-provider deliberately disables VPN injection
# and fresh staging agents never receive a headscale_ip (#15347).
sudo sed -i "/^AGENT_ROUTER_ALLOW_BRIDGE_HOST_FALLBACK=/d" "$ENV_FILE"
for kv in \
"HEADSCALE_API_URL=$HEADSCALE_API_URL" \
"HEADSCALE_PUBLIC_URL=$HEADSCALE_PUBLIC_URL" \
"HEADSCALE_API_KEY=$HEADSCALE_API_KEY" \
"CONTAINERS_SSH_KEY=$CONTAINERS_SSH_KEY" \
"SANDBOX_REGISTRY_REDIS_URL=$SANDBOX_REGISTRY_REDIS_URL" \
"DATABASE_URL=$DATABASE_URL" \
"SECRETS_MASTER_KEY=$SECRETS_MASTER_KEY" \
"ELIZA_AGENT_IMAGE=$ELIZA_AGENT_IMAGE" \
"ELIZA_CLOUD_AGENT_BASE_DOMAIN=$ELIZA_CLOUD_AGENT_BASE_DOMAIN"; do
key="${kv%%=*}"
val="${kv#*=}"
[ -n "$val" ] || continue
sudo sed -i "/^${key}=/d" "$ENV_FILE"
printf '%s=%s\n' "$key" "$val" | sudo tee -a "$ENV_FILE" >/dev/null
done
sudo chmod 600 "$ENV_FILE"
sudo systemctl restart "$SYSTEMD_UNIT"
sudo systemctl restart eliza-agent-router.service
echo "=== Restart issued for both daemons ==="
- name: Health check
if: steps.deploy_config.outputs.configured == 'true'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2
with:
host: ${{ env.DEPLOY_HOST }}
username: deploy
key: ${{ env.DEPLOY_SSH_KEY }}
command_timeout: 5m
envs: SYSTEMD_UNIT,ELIZA_CLOUD_AGENT_BASE_DOMAIN
script: |
set -euo pipefail
ENV_FILE=/opt/eliza/cloud/.env.local
ACTUAL_AGENT_BASE_DOMAIN=$(sudo awk -F= '$1=="ELIZA_CLOUD_AGENT_BASE_DOMAIN" { value=$2 } END { print value }' "$ENV_FILE")
if [ "$ACTUAL_AGENT_BASE_DOMAIN" != "$ELIZA_CLOUD_AGENT_BASE_DOMAIN" ]; then
echo "::error::Agent router base-domain drift: expected $ELIZA_CLOUD_AGENT_BASE_DOMAIN, found ${ACTUAL_AGENT_BASE_DOMAIN:-<missing>}."
exit 1
fi
# The worker logs to journald via systemd's stdout/stderr capture.
# Info-level logs are filtered unless VERBOSE_LOGGING=true, so we
# rely on systemd's own signals: process is active AND no fatal
# error / restart-loop in the journal since deploy.
HEALTH_SINCE_TS="$(date -u '+%Y-%m-%d %H:%M:%S')"
STABLE_THRESHOLD_SEC=20
FATAL_LOG_PATTERN="\[(provisioning-worker|agent-router)\] (fatal|unhandled rejection)|node:internal/.*Error:|Error \[ERR_|^[A-Za-z]+Error:"
for attempt in $(seq 1 18); do
if sudo systemctl is-active --quiet "$SYSTEMD_UNIT"; then
JOURNAL=$(sudo journalctl -u "$SYSTEMD_UNIT" --since "$HEALTH_SINCE_TS" --no-pager 2>/dev/null || true)
if echo "$JOURNAL" | grep -qE "$FATAL_LOG_PATTERN"; then
echo "::error::Worker logged a fatal error since deploy."
echo "$JOURNAL" | tail -n 50
exit 1
fi
# Active + uptime past the threshold = healthy. systemd's Restart=always
# means a crashing process would resurrect; an uptime > threshold
# therefore proves it stayed up across at least one poll cycle.
UPTIME_SEC=$(systemctl show "$SYSTEMD_UNIT" --property=ActiveEnterTimestampMonotonic --value | awk '{ print int($1 / 1e6) }')
NOW_SEC=$(awk '{print int($1)}' /proc/uptime)
AGE=$(( NOW_SEC - UPTIME_SEC ))
if [ "$AGE" -ge "$STABLE_THRESHOLD_SEC" ]; then
if ! sudo systemctl is-active --quiet eliza-agent-router.service; then
echo "Health check attempt $attempt/18: worker stable (${AGE}s), router not active yet."
sleep 5
continue
fi
ROUTER_JOURNAL=$(sudo journalctl -u eliza-agent-router.service --since "$HEALTH_SINCE_TS" --no-pager 2>/dev/null || true)
if echo "$ROUTER_JOURNAL" | grep -qE "$FATAL_LOG_PATTERN"; then
echo "::error::Agent router logged a fatal error since activation."
echo "$ROUTER_JOURNAL" | tail -n 50
exit 1
fi
ROUTER_UPTIME_SEC=$(systemctl show eliza-agent-router.service --property=ActiveEnterTimestampMonotonic --value | awk '{ print int($1 / 1e6) }')
ROUTER_AGE=$(( NOW_SEC - ROUTER_UPTIME_SEC ))
if [ "$ROUTER_AGE" -lt "$STABLE_THRESHOLD_SEC" ]; then
echo "Health check attempt $attempt/18: worker stable (${AGE}s), router only ${ROUTER_AGE}s old."
sleep 5
continue
fi
ROUTER_PORT=$(systemctl show eliza-agent-router.service --property=Environment --value | tr ' ' '\n' | awk -F= '$1=="AGENT_ROUTER_PORT" {print $2}')
: "${ROUTER_PORT:=3458}"
if curl -sf -m 3 "http://127.0.0.1:${ROUTER_PORT}/healthz" >/dev/null 2>&1; then
echo "Both daemons active and stable (worker ${AGE}s, router ${ROUTER_AGE}s, router /healthz OK) on attempt $attempt."
exit 0
fi
echo "Health check attempt $attempt/18: worker stable (${AGE}s), router ${ROUTER_AGE}s but /healthz not ready on port ${ROUTER_PORT}."
sleep 5
continue
fi
echo "Health check attempt $attempt/18: active but only ${AGE}s old, waiting for stability..."
else
echo "Health check attempt $attempt/18: not active yet."
fi
sleep 5
done
echo "::error::$SYSTEMD_UNIT failed to become healthy within 90s."
sudo systemctl status "$SYSTEMD_UNIT" --no-pager || true
sudo journalctl -u "$SYSTEMD_UNIT" -n 200 --no-pager || true
sudo systemctl status eliza-agent-router.service --no-pager || true
sudo journalctl -u eliza-agent-router.service -n 200 --no-pager || true
exit 1
- name: Notify Discord (Success)
if: success() && steps.deploy_config.outputs.configured == 'true'
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
ack_no_webhook: true
title: "🚀 Eliza Provisioning Worker Deployed"
nodetail: true
description: |
Branch: develop
Commit: ${{ github.sha }}
color: 0x00ff00
- name: Notify Discord (Failure)
if: failure() && steps.deploy_config.outputs.configured == 'true'
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
ack_no_webhook: true
title: "❌ Eliza Provisioning Worker Deploy Failed"
nodetail: true
description: |
Branch: develop
Commit: ${{ github.sha }}
color: 0xff0000
# ci-touch: re-trigger prod daemon deploy (dispatch runs were runner-starved) 2026-06-21