The deploy job hard-failed without CRYPTO_API_TOKEN, and /api/ws (which skips Basic auth) relied on that token. Drop the requirement: the app now works with no token at all, matching the backend guard that was always a no-op when unset. When a token is configured it still protects /api and /api/ws; when it is not, /api/ws is as open as the rest of the API, which the Traefik Basic auth still guards for everything else. Adds tests for the tokenless API and WebSocket.
250 lines
10 KiB
YAML
250 lines
10 KiB
YAML
name: CI/CD
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
pull_request:
|
|
branches: [main]
|
|
paths-ignore:
|
|
- '**.md'
|
|
push:
|
|
branches: [main]
|
|
paths-ignore:
|
|
- '**.md'
|
|
|
|
# Never let two deploys interleave (build/up/rollback flapping).
|
|
concurrency:
|
|
group: crypto-trading-deploy
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
backend:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
cache: "pip"
|
|
cache-dependency-path: backend/requirements.txt
|
|
|
|
- name: Install dependencies
|
|
run: pip install -r backend/requirements-dev.txt
|
|
|
|
- name: Lint (ruff)
|
|
run: |
|
|
cd backend
|
|
ruff check .
|
|
ruff format --check .
|
|
|
|
- name: Run tests
|
|
run: cd backend && python -m pytest -q
|
|
|
|
frontend:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "22"
|
|
cache: "npm"
|
|
cache-dependency-path: frontend/package-lock.json
|
|
|
|
- name: Install dependencies
|
|
run: cd frontend && npm ci
|
|
|
|
- name: Test
|
|
run: cd frontend && npm test
|
|
|
|
- name: Build
|
|
run: cd frontend && npm run build
|
|
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
needs: [backend, frontend]
|
|
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Log in to Docker Hub (optional)
|
|
env:
|
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
run: |
|
|
if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
|
|
echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
|
else
|
|
echo "Docker Hub credentials not set; using anonymous pulls"
|
|
fi
|
|
|
|
- name: Build backend
|
|
run: |
|
|
for i in 1 2 3; do
|
|
docker build -t crypto_trading-backend ./backend && exit 0
|
|
echo "backend build failed (attempt $i); retrying in 30s"; sleep 30
|
|
done
|
|
exit 1
|
|
|
|
- name: Build frontend
|
|
run: |
|
|
for i in 1 2 3; do
|
|
docker build -t crypto_trading-frontend ./frontend && exit 0
|
|
echo "frontend build failed (attempt $i); retrying in 30s"; sleep 30
|
|
done
|
|
exit 1
|
|
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
needs: build
|
|
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
|
env:
|
|
BASE_DOMAIN: ${{ secrets.BASE_DOMAIN || 'matitos.org' }}
|
|
# htpasswd string for the WebUI. Prefer the crypto-trading specific
|
|
# variable/secret, falling back to the shared org-level SCA users. The
|
|
# compose basicauth middleware is fail-closed, so it must be present for
|
|
# every `docker compose` command in this job to interpolate the file.
|
|
CRYPTO_FRONTEND_AUTH_USERS: ${{ vars.CRYPTO_FRONTEND_AUTH_USERS || secrets.CRYPTO_FRONTEND_AUTH_USERS || vars.SCA_FRONTEND_AUTH_USERS || secrets.SCA_FRONTEND_AUTH_USERS }}
|
|
CRYPTO_MODE: ${{ vars.CRYPTO_MODE || 'paper' }}
|
|
CRYPTO_EXCHANGE: ${{ vars.CRYPTO_EXCHANGE || 'binance' }}
|
|
CRYPTO_SYMBOL: ${{ vars.CRYPTO_SYMBOL || 'BTC/USDT' }}
|
|
CRYPTO_TIMEFRAME: ${{ vars.CRYPTO_TIMEFRAME || '5m' }}
|
|
CRYPTO_POLL_INTERVAL: ${{ vars.CRYPTO_POLL_INTERVAL || '5' }}
|
|
CRYPTO_STARTING_BALANCE: ${{ vars.CRYPTO_STARTING_BALANCE || '10000' }}
|
|
CRYPTO_CORS_ORIGINS: ${{ vars.CRYPTO_CORS_ORIGINS || format('https://crypto.{0}', secrets.BASE_DOMAIN || 'matitos.org') }}
|
|
CRYPTO_EXCHANGE_API_KEY: ${{ secrets.CRYPTO_EXCHANGE_API_KEY }}
|
|
CRYPTO_EXCHANGE_API_SECRET: ${{ secrets.CRYPTO_EXCHANGE_API_SECRET }}
|
|
CRYPTO_EXCHANGE_SANDBOX: ${{ vars.CRYPTO_EXCHANGE_SANDBOX || 'false' }}
|
|
# Risk controls (0 disables a limit).
|
|
CRYPTO_MAX_DAILY_LOSS: ${{ vars.CRYPTO_MAX_DAILY_LOSS || '0' }}
|
|
CRYPTO_MAX_DRAWDOWN_PCT: ${{ vars.CRYPTO_MAX_DRAWDOWN_PCT || '0' }}
|
|
CRYPTO_MAX_POSITION_PCT: ${{ vars.CRYPTO_MAX_POSITION_PCT || '1.0' }}
|
|
CRYPTO_MAX_TRADES_PER_DAY: ${{ vars.CRYPTO_MAX_TRADES_PER_DAY || '0' }}
|
|
# Notifications.
|
|
CRYPTO_TELEGRAM_BOT_TOKEN: ${{ secrets.CRYPTO_TELEGRAM_BOT_TOKEN }}
|
|
CRYPTO_TELEGRAM_CHAT_ID: ${{ vars.CRYPTO_TELEGRAM_CHAT_ID || secrets.CRYPTO_TELEGRAM_CHAT_ID }}
|
|
CRYPTO_NOTIFY_WEBHOOK_URL: ${{ secrets.CRYPTO_NOTIFY_WEBHOOK_URL }}
|
|
# Optional API bearer token (injected server-side by the frontend nginx).
|
|
CRYPTO_API_TOKEN: ${{ secrets.CRYPTO_API_TOKEN }}
|
|
CRYPTO_API_TOKEN_HEADER: ${{ vars.CRYPTO_API_TOKEN_HEADER || 'X-API-Token' }}
|
|
CRYPTO_AUTH_RATE_LIMIT_AVERAGE: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_AVERAGE || '10' }}
|
|
CRYPTO_AUTH_RATE_LIMIT_BURST: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_BURST || '5' }}
|
|
# Persistent data root: repo var wins, else derive from the org
|
|
# PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback.
|
|
CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }}
|
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Require CRYPTO_FRONTEND_AUTH_USERS
|
|
run: |
|
|
# The compose basicauth middleware is fail-closed, so refuse early
|
|
# with a clear message instead of starting an unauthenticated UI that
|
|
# can control a trading bot.
|
|
if [ -z "$CRYPTO_FRONTEND_AUTH_USERS" ]; then
|
|
echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy"
|
|
exit 1
|
|
fi
|
|
# CRYPTO_API_TOKEN is optional. When unset, the API and the /api/ws
|
|
# live feed are unauthenticated beyond the Traefik Basic auth that
|
|
# still guards everything except /api/ws. Set it to require a bearer
|
|
# token on /api (nginx injects it server-side, so the browser never
|
|
# sees it).
|
|
if [ -z "$CRYPTO_API_TOKEN" ]; then
|
|
echo "Note: CRYPTO_API_TOKEN is not set; /api and /api/ws will not require a bearer token."
|
|
fi
|
|
|
|
- name: Log in to Docker Hub (optional)
|
|
run: |
|
|
if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
|
|
echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
|
else
|
|
echo "Docker Hub credentials not set; using anonymous pulls"
|
|
fi
|
|
|
|
- name: Deploy with Docker Compose
|
|
run: |
|
|
echo "Deploying to crypto.${BASE_DOMAIN} (mode=${CRYPTO_MODE})"
|
|
echo "Data dir: ${CRYPTO_DATA_DIR}"
|
|
# Keep the currently-running images so we can roll back on failure.
|
|
for img in crypto_trading-backend crypto_trading-frontend; do
|
|
if docker image inspect "$img" >/dev/null 2>&1; then
|
|
docker tag "$img" "${img}:prev"
|
|
fi
|
|
done
|
|
built=0
|
|
for i in 1 2 3 4 5; do
|
|
if docker compose build; then built=1; break; fi
|
|
echo "docker compose build failed (attempt $i); retrying in 30s"
|
|
sleep 30
|
|
done
|
|
[ "$built" = "1" ] || { echo "::error::compose build failed after retries"; exit 1; }
|
|
# Bring the new stack up and wait for healthchecks. No pre-`down`, so
|
|
# the old containers keep serving until the new ones are healthy.
|
|
if ! docker compose up -d --remove-orphans --wait --wait-timeout 300; then
|
|
echo "::error::New stack is unhealthy; rolling back to previous images"
|
|
docker compose down --remove-orphans || true
|
|
for img in crypto_trading-backend crypto_trading-frontend; do
|
|
if docker image inspect "${img}:prev" >/dev/null 2>&1; then
|
|
docker tag "${img}:prev" "$img"
|
|
fi
|
|
done
|
|
docker compose up -d --remove-orphans --wait --wait-timeout 300 || true
|
|
exit 1
|
|
fi
|
|
|
|
- name: Services and recent logs
|
|
run: |
|
|
docker compose ps
|
|
docker compose logs --tail=80
|
|
|
|
- name: Health check (HTTP)
|
|
run: |
|
|
probe_backend() {
|
|
docker compose exec -T backend python -c \
|
|
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5).status==200 else 1)"
|
|
}
|
|
probe_frontend() {
|
|
docker compose exec -T frontend wget -q --spider http://127.0.0.1:80/
|
|
}
|
|
for i in 1 2 3 4 5 6; do
|
|
echo "Attempt $i/6..."
|
|
if probe_backend && probe_frontend; then
|
|
echo "All services healthy"
|
|
exit 0
|
|
fi
|
|
sleep 15
|
|
done
|
|
echo "::error::Health checks failed; rolling back"
|
|
docker compose down --remove-orphans || true
|
|
for img in crypto_trading-backend crypto_trading-frontend; do
|
|
if docker image inspect "${img}:prev" >/dev/null 2>&1; then
|
|
docker tag "${img}:prev" "$img"
|
|
fi
|
|
done
|
|
docker compose up -d --remove-orphans --wait --wait-timeout 300 || true
|
|
exit 1
|
|
|
|
cleanup:
|
|
runs-on: ubuntu-latest
|
|
needs: [backend, frontend]
|
|
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Delete branches merged into main
|
|
run: |
|
|
git fetch --all --prune
|
|
git branch -r --merged origin/main \
|
|
| grep -vE 'origin/(main|HEAD)([[:space:]]|$)' \
|
|
| sed -E 's|^[[:space:]]*origin/||' \
|
|
| while read -r branch; do
|
|
[ -z "$branch" ] && continue
|
|
echo "Deleting merged branch: $branch"
|
|
git push origin --delete "$branch" || echo " (could not delete $branch)"
|
|
done
|