mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:35:42 +00:00
591 lines
27 KiB
YAML
591 lines
27 KiB
YAML
name: Docker Build and Push
|
|
|
|
on:
|
|
push:
|
|
branches: [ "main" ]
|
|
paths:
|
|
# Note: C++ changes trigger shardok_arm64_build.yml instead
|
|
# Note: Auth changes trigger auth_build.yml instead
|
|
# Note: Windows installer changes trigger installer_build.yml instead
|
|
- 'src/main/go/**'
|
|
- '!src/main/go/net/eagle0/authservice/**'
|
|
- '!src/main/go/net/eagle0/authcli/**'
|
|
- '!src/main/go/net/eagle0/clients/**'
|
|
- 'src/main/scala/**'
|
|
- 'src/main/protobuf/**'
|
|
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
|
- '!src/main/protobuf/net/eagle0/eagle/api/admin/**'
|
|
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
|
- 'src/main/resources/**'
|
|
- 'ci/BUILD.bazel'
|
|
- 'ci/github_actions/ensure_bazel_installed.sh'
|
|
- 'scripts/sync_bazel_xcode.sh'
|
|
- '.github/actions/setup-bazel/**'
|
|
- 'MODULE.bazel'
|
|
- 'MODULE.bazel.lock'
|
|
- '.bazelrc'
|
|
- 'docker-compose.prod.yml'
|
|
- 'nginx/**'
|
|
- '.github/workflows/docker_build.yml'
|
|
pull_request:
|
|
paths:
|
|
# Note: Auth changes trigger auth_build.yml instead
|
|
# Note: Windows installer changes trigger installer_build.yml instead
|
|
- 'src/main/go/net/eagle0/admin_server/**'
|
|
- 'src/main/go/net/eagle0/common/**'
|
|
- 'src/main/go/net/eagle0/util/**'
|
|
- 'src/main/scala/**'
|
|
- 'src/main/protobuf/**'
|
|
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
|
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
|
- 'src/main/resources/**'
|
|
- 'ci/BUILD.bazel'
|
|
- 'ci/github_actions/ensure_bazel_installed.sh'
|
|
- 'ci/github_actions/fetch_lfs.sh'
|
|
- 'scripts/sync_bazel_xcode.sh'
|
|
- '.github/actions/setup-bazel/**'
|
|
- 'go.mod'
|
|
- 'go.sum'
|
|
- 'MODULE.bazel'
|
|
- 'MODULE.bazel.lock'
|
|
- '.bazelrc'
|
|
- 'docker-compose.prod.yml'
|
|
- 'nginx/**'
|
|
- '.github/workflows/docker_build.yml'
|
|
workflow_dispatch:
|
|
inputs:
|
|
push_images:
|
|
description: 'Push images to container registry'
|
|
required: true
|
|
default: 'false'
|
|
type: boolean
|
|
verify_gitea_lfs:
|
|
description: 'Force LFS download from an empty temporary cache'
|
|
required: true
|
|
default: 'false'
|
|
type: boolean
|
|
|
|
# Only allow one deployment at a time; queue new ones (never cancel a running deploy).
|
|
# The build-all job checks if it's still the latest commit on main and skips if not,
|
|
# so intermediate commits don't waste time building when a newer one is already queued.
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || 'deploy' }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
permissions:
|
|
contents: read
|
|
actions: write # Required to delete artifacts after deploy
|
|
|
|
jobs:
|
|
# Single consolidated build job - builds all images with one bazel invocation
|
|
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
|
|
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
|
|
build-all:
|
|
runs-on: [self-hosted, bazel]
|
|
outputs:
|
|
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
|
|
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
|
|
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
|
|
skip_build: ${{ steps.check-latest.outputs.skip }}
|
|
steps:
|
|
- name: Skip if superseded
|
|
if: github.event_name != 'pull_request'
|
|
id: check-latest
|
|
run: |
|
|
# Check if there's a newer run of this workflow waiting in the queue.
|
|
# If so, skip this build — the queued run will deploy a newer commit.
|
|
QUEUED=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
|
|
"https://api.github.com/repos/${{ github.repository }}/actions/workflows/docker_build.yml/runs?status=queued" \
|
|
| python3 -c "import sys,json; runs=json.load(sys.stdin).get('workflow_runs',[]); print(len([r for r in runs if r['run_number'] > ${{ github.run_number }}]))")
|
|
if [ "$QUEUED" -gt 0 ]; then
|
|
echo "::notice::Skipping build — $QUEUED newer run(s) queued for this workflow"
|
|
echo "skip=true" >> $GITHUB_OUTPUT
|
|
fi
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
|
|
- name: Ensure Git LFS available for checkout
|
|
if: steps.check-latest.outputs.skip != 'true'
|
|
run: |
|
|
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
export PATH="${COMMON_PATHS}:${PATH}"
|
|
for path in /opt/homebrew/bin /usr/local/bin; do
|
|
if [ -d "$path" ]; then
|
|
echo "$path" >> "$GITHUB_PATH"
|
|
fi
|
|
done
|
|
if ! command -v git-lfs >/dev/null 2>&1; then
|
|
brew install git-lfs
|
|
fi
|
|
git-lfs --version
|
|
|
|
- name: Checkout repository
|
|
if: steps.check-latest.outputs.skip != 'true'
|
|
uses: actions/checkout@v7
|
|
env:
|
|
GIT_LFS_SKIP_SMUDGE: 1
|
|
with:
|
|
persist-credentials: false
|
|
lfs: false
|
|
|
|
- name: Ensure Bazel installed
|
|
if: steps.check-latest.outputs.skip != 'true'
|
|
uses: ./.github/actions/setup-bazel
|
|
|
|
- name: Sync Bazel Xcode config
|
|
if: steps.check-latest.outputs.skip != 'true'
|
|
run: ./scripts/sync_bazel_xcode.sh
|
|
|
|
- name: Fetch LFS files needed for admin server
|
|
if: steps.check-latest.outputs.skip != 'true'
|
|
env:
|
|
GITEA_LFS_TOKEN: ${{ secrets.GITEA_LFS_TOKEN }}
|
|
GITEA_LFS_USERNAME: ${{ secrets.GITEA_LFS_USERNAME }}
|
|
GITEA_LFS_VERIFY_FRESH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.verify_gitea_lfs == 'true' }}
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: ./ci/github_actions/fetch_lfs.sh --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
|
|
|
|
- name: Build all Docker images
|
|
if: steps.check-latest.outputs.skip != 'true'
|
|
id: build-all
|
|
run: |
|
|
set -ex
|
|
|
|
# Build ALL images in a single bazel command - Bazel parallelizes internally
|
|
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
|
|
# --stamp is needed for admin_server x_defs (git commit, build time)
|
|
echo "=== Building Docker images ==="
|
|
bazel build \
|
|
--stamp \
|
|
--platforms=//:linux_x86_64 \
|
|
--extra_toolchains=@llvm_toolchain_linux//:cc-toolchain-x86_64-linux \
|
|
//ci:eagle_server_image \
|
|
//ci:admin_server_image \
|
|
//ci:jfr_sidecar_image \
|
|
//ci:warmup_tar
|
|
|
|
# Extract warmup binary from tar for deployment
|
|
mkdir -p scripts/bin
|
|
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
|
|
|
|
# Save all image paths before any other bazel command changes bazel-bin symlink
|
|
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
|
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
|
|
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
|
|
|
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
|
|
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
|
|
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
|
|
|
|
echo "=== Image paths ==="
|
|
echo "Eagle: $EAGLE_PATH"
|
|
echo "Admin: $ADMIN_PATH"
|
|
echo "JFR Sidecar: $JFR_PATH"
|
|
|
|
- name: Upload warmup binary
|
|
if: steps.check-latest.outputs.skip != 'true' && github.event_name != 'pull_request'
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: warmup-binary
|
|
path: scripts/bin/warmup
|
|
retention-days: 1
|
|
|
|
- name: Login to DigitalOcean Container Registry
|
|
if: steps.check-latest.outputs.skip != 'true' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true'))
|
|
env:
|
|
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
|
run: |
|
|
mkdir -p ~/.docker
|
|
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
|
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
|
mkdir -p .docker
|
|
cp ~/.docker/config.json .docker/
|
|
|
|
- name: Push all images to DO registry
|
|
id: push-images
|
|
if: steps.check-latest.outputs.skip != 'true' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true'))
|
|
env:
|
|
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
|
run: |
|
|
set -ex
|
|
|
|
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
|
|
|
# Get crane (cached across runs to avoid rebuilding bazel target)
|
|
CRANE_VERSION="v0.20.2"
|
|
CRANE_DIR="${HOME}/.local/bin"
|
|
CRANE="${CRANE_DIR}/crane"
|
|
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
|
|
echo "Installing crane ${CRANE_VERSION}..."
|
|
mkdir -p "$CRANE_DIR"
|
|
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
|
|
chmod +x "$CRANE"
|
|
else
|
|
echo "Using cached crane at $CRANE"
|
|
fi
|
|
|
|
# Set up image paths and tags
|
|
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
|
|
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
|
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
|
|
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
|
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
|
|
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
|
|
|
# Push all three images in parallel
|
|
echo "Pushing Eagle: $EAGLE_TAG"
|
|
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG" &
|
|
PID_EAGLE=$!
|
|
|
|
echo "Pushing Admin: $ADMIN_TAG"
|
|
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG" &
|
|
PID_ADMIN=$!
|
|
|
|
echo "Pushing JFR Sidecar: $JFR_TAG"
|
|
$CRANE push "$JFR_IMAGE" "$JFR_TAG" &
|
|
PID_JFR=$!
|
|
|
|
# Wait for all pushes to complete
|
|
PUSH_FAILED=0
|
|
wait $PID_EAGLE || PUSH_FAILED=1
|
|
wait $PID_ADMIN || PUSH_FAILED=1
|
|
wait $PID_JFR || PUSH_FAILED=1
|
|
if [ $PUSH_FAILED -ne 0 ]; then
|
|
echo "ERROR: One or more image pushes failed"
|
|
exit 1
|
|
fi
|
|
echo "All images pushed, tagging :latest..."
|
|
|
|
# Tag :latest in parallel
|
|
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest" &
|
|
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest" &
|
|
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest" &
|
|
wait
|
|
|
|
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
|
|
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
|
|
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
|
|
|
|
echo "=== All images pushed successfully ==="
|
|
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
needs: [build-all]
|
|
if: needs.build-all.outputs.skip_build != 'true' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true'))
|
|
environment: production
|
|
env:
|
|
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
|
|
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
|
|
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
|
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
|
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
|
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
|
DO_SPACES_ENDPOINT: ${{ secrets.DO_SPACES_ENDPOINT }}
|
|
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
|
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
|
|
# Admin server uses different credentials (for eagle0-assets bucket)
|
|
ADMIN_S3_ACCESS_KEY: ${{ secrets.ACCESS_KEY_ID }}
|
|
ADMIN_S3_SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
|
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
|
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
|
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
|
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
|
|
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
|
|
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
|
|
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
|
|
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
|
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
|
|
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
|
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
|
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
|
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
|
EAGLE_HISTORY_BACKEND: ${{ secrets.EAGLE_HISTORY_BACKEND }}
|
|
EAGLE_POSTGRES_HOST: ${{ secrets.EAGLE_POSTGRES_HOST }}
|
|
EAGLE_POSTGRES_PORT: ${{ secrets.EAGLE_POSTGRES_PORT }}
|
|
EAGLE_POSTGRES_DATABASE: ${{ secrets.EAGLE_POSTGRES_DATABASE }}
|
|
EAGLE_POSTGRES_USER: ${{ secrets.EAGLE_POSTGRES_USER }}
|
|
EAGLE_POSTGRES_PASSWORD: ${{ secrets.EAGLE_POSTGRES_PASSWORD }}
|
|
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
|
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
|
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
|
|
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
|
|
steps:
|
|
- name: Ensure Git LFS available for checkout
|
|
run: |
|
|
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
export PATH="${COMMON_PATHS}:${PATH}"
|
|
for path in /opt/homebrew/bin /usr/local/bin; do
|
|
if [ -d "$path" ]; then
|
|
echo "$path" >> "$GITHUB_PATH"
|
|
fi
|
|
done
|
|
if ! command -v git-lfs >/dev/null 2>&1; then
|
|
brew install git-lfs
|
|
fi
|
|
git-lfs --version
|
|
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v7
|
|
env:
|
|
GIT_LFS_SKIP_SMUDGE: 1
|
|
with:
|
|
persist-credentials: false
|
|
lfs: false
|
|
|
|
- name: Setup SSH key
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
|
|
chmod 600 ~/.ssh/deploy_key
|
|
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
|
|
- name: Download warmup binary
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: warmup-binary
|
|
path: scripts/bin/
|
|
|
|
- name: Copy config files to droplet
|
|
run: |
|
|
# Create directory structure on remote
|
|
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
|
|
set -e
|
|
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx
|
|
rm -f /opt/eagle0/scripts/bin/warmup
|
|
SETUP_DIRS
|
|
|
|
# Copy files
|
|
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
|
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
|
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh scripts/eagle-logs.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
|
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
|
|
|
- name: Deploy to production droplet
|
|
run: |
|
|
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
|
set -e
|
|
cd /opt/eagle0
|
|
|
|
# =================================================================
|
|
# CRITICAL: Validate environment variables before proceeding
|
|
# This catches GitHub Actions secret store hiccups early
|
|
# =================================================================
|
|
validate_env() {
|
|
local var_name="\$1"
|
|
local var_value="\$2"
|
|
local default_value="\${3:-}"
|
|
|
|
if [ -z "\${var_value}" ]; then
|
|
echo "ERROR: \${var_name} is empty. GitHub Actions secrets may have failed to load."
|
|
echo "Please retry the workflow."
|
|
return 1
|
|
fi
|
|
|
|
# Check if we got a default value instead of the real secret
|
|
if [ -n "\${default_value}" ] && [ "\${var_value}" = "\${default_value}" ]; then
|
|
echo "ERROR: \${var_name} has default value '\${default_value}' instead of the actual secret."
|
|
echo "This indicates GitHub Actions secrets failed to load. Please retry the workflow."
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
echo "Validating critical environment variables..."
|
|
|
|
# These are the raw values from GitHub Actions (before export)
|
|
# We check them before exporting to catch issues early
|
|
VALIDATION_FAILED=0
|
|
|
|
validate_env "SHARDOK_ADDRESS" "${SHARDOK_ADDRESS}" "" || VALIDATION_FAILED=1
|
|
validate_env "EAGLE_IMAGE" "${EAGLE_IMAGE}" "" || VALIDATION_FAILED=1
|
|
validate_env "JWT_PRIVATE_KEY" "${JWT_PRIVATE_KEY}" "" || VALIDATION_FAILED=1
|
|
validate_env "DO_REGISTRY_TOKEN" "${DO_REGISTRY_TOKEN}" "" || VALIDATION_FAILED=1
|
|
validate_env "EAGLE_POSTGRES_HOST" "${EAGLE_POSTGRES_HOST}" "" || VALIDATION_FAILED=1
|
|
validate_env "EAGLE_POSTGRES_DATABASE" "${EAGLE_POSTGRES_DATABASE}" "" || VALIDATION_FAILED=1
|
|
validate_env "EAGLE_POSTGRES_USER" "${EAGLE_POSTGRES_USER}" "" || VALIDATION_FAILED=1
|
|
validate_env "EAGLE_POSTGRES_PASSWORD" "${EAGLE_POSTGRES_PASSWORD}" "" || VALIDATION_FAILED=1
|
|
|
|
if [ "\${VALIDATION_FAILED}" -eq 1 ]; then
|
|
echo ""
|
|
echo "========================================="
|
|
echo "DEPLOYMENT ABORTED: Missing critical secrets"
|
|
echo "This is likely a transient GitHub Actions issue."
|
|
echo "Please retry the workflow."
|
|
echo "========================================="
|
|
exit 1
|
|
fi
|
|
|
|
echo "All critical environment variables validated successfully."
|
|
|
|
# =================================================================
|
|
# Export environment variables for docker compose
|
|
# These are passed via heredoc and exported so child processes (docker compose) can access them
|
|
export EAGLE_IMAGE="${EAGLE_IMAGE}"
|
|
export ADMIN_IMAGE="${ADMIN_IMAGE}"
|
|
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
|
export OPENAI_API_KEY="${OPENAI_API_KEY}"
|
|
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
|
|
export GEMINI_API_KEY="${GEMINI_API_KEY}"
|
|
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
|
|
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
|
|
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
|
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
|
|
export ADMIN_S3_ACCESS_KEY="${ADMIN_S3_ACCESS_KEY}"
|
|
export ADMIN_S3_SECRET_KEY="${ADMIN_S3_SECRET_KEY}"
|
|
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
|
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
|
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
|
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
|
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
|
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
|
|
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
|
|
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
|
|
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
|
|
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
|
|
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
|
|
export TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
|
|
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
|
|
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
|
|
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
|
|
export SENTRY_DSN="${SENTRY_DSN}"
|
|
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
|
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
|
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
|
export EAGLE_HISTORY_BACKEND="${EAGLE_HISTORY_BACKEND:-postgres}"
|
|
export EAGLE_POSTGRES_HOST="${EAGLE_POSTGRES_HOST}"
|
|
export EAGLE_POSTGRES_PORT="${EAGLE_POSTGRES_PORT}"
|
|
export EAGLE_POSTGRES_DATABASE="${EAGLE_POSTGRES_DATABASE}"
|
|
export EAGLE_POSTGRES_USER="${EAGLE_POSTGRES_USER}"
|
|
export EAGLE_POSTGRES_PASSWORD="${EAGLE_POSTGRES_PASSWORD}"
|
|
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
|
export NOTIFY_SECRET="${NOTIFY_SECRET}"
|
|
export GITHUB_TOKEN="${GITHUB_TOKEN_FOR_ADMIN}"
|
|
|
|
# Check Docker has IPv6 support
|
|
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
|
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
|
fi
|
|
|
|
# Login to registry
|
|
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
|
|
|
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
|
|
|
# Install crane for pulling OCI images (cached across deploys)
|
|
CRANE_VERSION="v0.20.2"
|
|
if [ ! -x crane ] || ! ./crane version 2>/dev/null | grep -q "0.20.2"; then
|
|
echo "Installing crane \${CRANE_VERSION}..."
|
|
rm -f crane
|
|
curl -sL "https://github.com/google/go-containerregistry/releases/download/\${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" | tar xzf - crane
|
|
chmod +x crane
|
|
else
|
|
echo "Using cached crane"
|
|
fi
|
|
|
|
# Pull all images in parallel, then load sequentially
|
|
echo "Pulling all images in parallel..."
|
|
./crane pull "\${EAGLE_IMAGE}" eagle.tar &
|
|
./crane pull "\${ADMIN_IMAGE}" admin.tar &
|
|
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar &
|
|
docker pull nginx:alpine &
|
|
docker pull certbot/certbot &
|
|
wait
|
|
|
|
echo "Loading images..."
|
|
docker load -i eagle.tar && rm eagle.tar
|
|
docker load -i admin.tar && rm admin.tar
|
|
# Tag as :latest locally so docker-compose fallback uses correct image
|
|
docker tag "\${ADMIN_IMAGE}" registry.digitalocean.com/eagle0/admin-server:latest
|
|
docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
|
|
|
|
echo "All images pulled and loaded successfully"
|
|
|
|
# =================================================================
|
|
# Verify Shardok connectivity before proceeding with deployment
|
|
# This catches network/firewall issues early
|
|
# =================================================================
|
|
echo "Verifying Shardok connectivity..."
|
|
SHARDOK_HOST=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f1)
|
|
SHARDOK_PORT=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f2)
|
|
|
|
# Try to connect to Shardok (timeout after 10 seconds)
|
|
if nc -z -w 10 "\${SHARDOK_HOST}" "\${SHARDOK_PORT}" 2>/dev/null; then
|
|
echo "Shardok connectivity verified: \${SHARDOK_ADDRESS} is reachable"
|
|
else
|
|
echo ""
|
|
echo "========================================="
|
|
echo "ERROR: Cannot reach Shardok at \${SHARDOK_ADDRESS}"
|
|
echo "This may indicate:"
|
|
echo " - Shardok server is not running on Hetzner"
|
|
echo " - Network/firewall issues between DigitalOcean and Hetzner"
|
|
echo " - Incorrect SHARDOK_ADDRESS configuration"
|
|
echo ""
|
|
echo "DEPLOYMENT ABORTED: Shardok must be reachable for battles to work."
|
|
echo "========================================="
|
|
exit 1
|
|
fi
|
|
|
|
# Stop local shardok container if running (now runs on Hetzner)
|
|
docker stop shardok-server 2>/dev/null || true
|
|
docker rm shardok-server 2>/dev/null || true
|
|
|
|
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
|
|
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
|
|
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
|
|
chmod +x /opt/eagle0/scripts/*.sh
|
|
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
|
|
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
|
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
|
|
|
|
# Verify
|
|
sleep 10
|
|
docker compose -f docker-compose.prod.yml ps
|
|
docker compose -f docker-compose.prod.yml images
|
|
|
|
# Verify admin container is running the correct image
|
|
echo "=== Verifying admin container image ==="
|
|
ADMIN_RUNNING_DIGEST=\$(docker inspect admin-server --format '{{.Image}}')
|
|
ADMIN_EXPECTED_DIGEST=\$(docker inspect "\${ADMIN_IMAGE}" --format '{{.Id}}')
|
|
echo "Expected image: \${ADMIN_IMAGE}"
|
|
echo "Expected digest: \${ADMIN_EXPECTED_DIGEST}"
|
|
echo "Running digest: \${ADMIN_RUNNING_DIGEST}"
|
|
if [ "\${ADMIN_RUNNING_DIGEST}" != "\${ADMIN_EXPECTED_DIGEST}" ]; then
|
|
echo "ERROR: Admin container is running wrong image!"
|
|
echo "Container entrypoint:"
|
|
docker inspect admin-server --format '{{.Config.Entrypoint}}'
|
|
exit 1
|
|
fi
|
|
echo "Admin image verification passed"
|
|
|
|
# Cleanup - remove old images to prevent disk full
|
|
docker container prune -f
|
|
docker image prune -f
|
|
# Remove images older than 24h (keeps recent images for rollback)
|
|
docker image prune -a -f --filter "until=24h" || true
|
|
DEPLOY_SCRIPT
|
|
|
|
cleanup:
|
|
needs: [build-all, deploy]
|
|
if: always() && github.event_name != 'pull_request'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Delete warmup-binary artifact
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
ARTIFACT_ID=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
|
|
-q '.artifacts[] | select(.name == "warmup-binary") | .id')
|
|
if [ -n "$ARTIFACT_ID" ]; then
|
|
echo "Deleting warmup-binary artifact (ID: $ARTIFACT_ID)"
|
|
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID" || true
|
|
fi
|