name: fquiz 镜像构建与部署 on: push: branches: - main workflow_dispatch: concurrency: group: fquiz-build-and-deploy cancel-in-progress: true permissions: contents: read packages: write env: REGISTRY: ghcr.io jobs: build-and-push: runs-on: ubuntu-latest outputs: api_image: ${{ steps.vars.outputs.api_image }} web_image: ${{ steps.vars.outputs.web_image }} image_tag: ${{ steps.vars.outputs.image_tag }} steps: - name: 拉取代码 uses: actions/checkout@v4 - name: 生成镜像变量 id: vars shell: bash run: | OWNER_LC="${GITHUB_REPOSITORY_OWNER,,}" echo "api_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-api" >> "$GITHUB_OUTPUT" echo "web_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-web" >> "$GITHUB_OUTPUT" echo "image_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" - name: 安装 Buildx uses: docker/setup-buildx-action@v3 - name: 登录 GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: 构建并推送 API 镜像 uses: docker/build-push-action@v6 with: context: ./api file: ./api/Dockerfile push: true build-args: | PYTHON_BASE_IMAGE=${{ vars.PYTHON_BASE_IMAGE || 'docker.m.daocloud.io/library/python:3.11-slim' }} PIP_INDEX_URL=${{ secrets.PIP_INDEX_URL || vars.PIP_INDEX_URL || 'https://pypi.org/simple' }} PIP_DEFAULT_TIMEOUT=${{ vars.PIP_DEFAULT_TIMEOUT || '300' }} PIP_RETRIES=${{ vars.PIP_RETRIES || '20' }} tags: | ${{ steps.vars.outputs.api_image }}:${{ steps.vars.outputs.image_tag }} ${{ steps.vars.outputs.api_image }}:latest cache-from: type=gha,scope=fquiz-api cache-to: type=gha,mode=max,scope=fquiz-api - name: 构建并推送 Web 镜像 uses: docker/build-push-action@v6 with: context: ./web file: ./web/Dockerfile push: true build-args: | NODE_BASE_IMAGE=${{ vars.NODE_BASE_IMAGE || 'docker.m.daocloud.io/library/node:22-alpine' }} NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' }} tags: | ${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }} ${{ steps.vars.outputs.web_image }}:latest cache-from: type=gha,scope=fquiz-web cache-to: type=gha,mode=max,scope=fquiz-web deploy: runs-on: ubuntu-latest needs: build-and-push if: github.ref == 'refs/heads/main' steps: - name: 校验部署参数 env: SERVER_HOST: ${{ secrets.SERVER_HOST || vars.SERVER_HOST }} SERVER_USER: ${{ secrets.SERVER_USER || vars.SERVER_USER }} SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }} SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }} run: | set -euo pipefail [ -n "${SERVER_HOST}" ] || { echo "::error::缺少 SERVER_HOST(请在 Secrets 或 Variables 中配置)"; exit 1; } [ -n "${SERVER_USER}" ] || { echo "::error::缺少 SERVER_USER(请在 Secrets 或 Variables 中配置)"; exit 1; } if [ -z "${SERVER_SSH_KEY}" ] && [ -z "${SERVER_PASSWORD}" ]; then echo "::error::缺少登录凭据:请至少配置 SERVER_SSH_KEY 或 SERVER_PASSWORD" exit 1 fi - name: 通过 SSH 拉取并更新容器 uses: appleboy/ssh-action@v1.2.0 env: DEPLOY_PATH: ${{ secrets.DEPLOY_PATH || vars.DEPLOY_PATH }} API_IMAGE: ${{ needs.build-and-push.outputs.api_image }} WEB_IMAGE: ${{ needs.build-and-push.outputs.web_image }} IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: host: ${{ secrets.SERVER_HOST || vars.SERVER_HOST }} username: ${{ secrets.SERVER_USER || vars.SERVER_USER }} port: ${{ secrets.SERVER_PORT || vars.SERVER_PORT || 22 }} timeout: 120s command_timeout: 45m key: ${{ secrets.SERVER_SSH_KEY }} password: ${{ secrets.SERVER_PASSWORD }} envs: DEPLOY_PATH,API_IMAGE,WEB_IMAGE,IMAGE_TAG,NEXT_PUBLIC_API_BASE_URL,GHCR_USERNAME,GHCR_TOKEN script: | set -euo pipefail export DOCKER_CLIENT_TIMEOUT="${DOCKER_CLIENT_TIMEOUT:-600}" export COMPOSE_HTTP_TIMEOUT="${COMPOSE_HTTP_TIMEOUT:-600}" DEPLOY_DIR="${DEPLOY_PATH:-/opt/fquiz}" mkdir -p "${DEPLOY_DIR}" cd "${DEPLOY_DIR}" cat > docker-compose.prod.yml <<'YAML' services: db: image: ${POSTGRES_IMAGE:-docker.m.daocloud.io/pgvector/pgvector:pg16} container_name: fquiz-db environment: POSTGRES_DB: ${POSTGRES_DB:-fquiz} POSTGRES_USER: ${POSTGRES_USER:-fquiz} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fquiz} ports: - "${POSTGRES_PORT:-5434}:5432" volumes: - fquiz_db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fquiz} -d ${POSTGRES_DB:-fquiz}"] interval: 10s timeout: 5s retries: 8 start_period: 10s restart: unless-stopped redis: image: ${REDIS_IMAGE:-docker.m.daocloud.io/library/redis:7-alpine} container_name: fquiz-redis command: redis-server --appendonly yes ports: - "${REDIS_PORT:-6379}:6379" volumes: - fquiz_redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 start_period: 5s restart: unless-stopped minio: image: ${MINIO_IMAGE:-minio/minio:latest} container_name: fquiz-minio command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} ports: - "${MINIO_API_PORT:-9000}:9000" - "${MINIO_CONSOLE_PORT:-9001}:9001" volumes: - fquiz_minio_data:/data restart: unless-stopped minio-init: image: ${MINIO_MC_IMAGE:-minio/mc:latest} container_name: fquiz-minio-init depends_on: minio: condition: service_started environment: MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} MINIO_BUCKET: ${MINIO_BUCKET:-fquiz-files} entrypoint: /bin/sh command: - -c - > until mc alias set local "$$MINIO_ENDPOINT" "$$MINIO_ACCESS_KEY" "$$MINIO_SECRET_KEY"; do sleep 1; done; mc mb -p "local/$$MINIO_BUCKET" || true; restart: "no" api: image: ${API_IMAGE} container_name: fquiz-api depends_on: db: condition: service_healthy minio: condition: service_started minio-init: condition: service_completed_successfully environment: API_HOST: ${API_HOST:-0.0.0.0} API_PORT: ${API_PORT:-8000} API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000} API_CORS_ORIGIN_REGEX: ${API_CORS_ORIGIN_REGEX:-} DATABASE_URL: ${DATABASE_URL:-} DB_HOST: ${DB_HOST:-db} DB_PORT: ${DB_PORT:-5432} DB_NAME: ${DB_NAME:-postgres} DB_SCHEMA: ${DB_SCHEMA:-public} DB_USERNAME: ${DB_USERNAME:-fquiz} DB_PASSWORD: ${DB_PASSWORD:-fquiz} FILE_VFS_ROOT: ${FILE_VFS_ROOT:-./data/vfs} MINIO_ENABLED: ${MINIO_ENABLED:-true} MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} MINIO_BUCKET: ${MINIO_BUCKET:-fquiz-files} MINIO_REGION: ${MINIO_REGION:-us-east-1} JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-480} REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false} REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax} LLM_PROVIDER_API_KEYS: ${LLM_PROVIDER_API_KEYS:-} LLM_REQUEST_TIMEOUT_SECONDS: ${LLM_REQUEST_TIMEOUT_SECONDS:-60} CHAT_CONTEXT_MESSAGE_LIMIT: ${CHAT_CONTEXT_MESSAGE_LIMIT:-12} CHAT_DEFAULT_SYSTEM_PROMPT: ${CHAT_DEFAULT_SYSTEM_PROMPT:-You are a helpful assistant.} CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1} CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai} SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60} WINE_BINARY_PATH: ${WINE_BINARY_PATH:-wine} WINE_ALLOWED_ROOT: ${WINE_ALLOWED_ROOT:-./data/wine} WINE_DEFAULT_TIMEOUT_SECONDS: ${WINE_DEFAULT_TIMEOUT_SECONDS:-300} WINE_MAX_TIMEOUT_SECONDS: ${WINE_MAX_TIMEOUT_SECONDS:-1800} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password} ports: - "${API_PORT:-8000}:8000" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"] interval: 10s timeout: 3s retries: 5 start_period: 10s restart: unless-stopped celery-worker: image: ${API_IMAGE} container_name: fquiz-celery-worker command: - celery - -A - app.core.celery_app.celery_app - worker - --loglevel=${CELERY_LOG_LEVEL:-INFO} - --concurrency=${CELERY_WORKER_CONCURRENCY:-2} depends_on: api: condition: service_healthy redis: condition: service_healthy environment: DATABASE_URL: ${DATABASE_URL:-} DB_HOST: ${DB_HOST:-db} DB_PORT: ${DB_PORT:-5432} DB_NAME: ${DB_NAME:-postgres} DB_SCHEMA: ${DB_SCHEMA:-public} DB_USERNAME: ${DB_USERNAME:-fquiz} DB_PASSWORD: ${DB_PASSWORD:-fquiz} CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1} CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai} SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60} restart: unless-stopped celery-beat: image: ${API_IMAGE} container_name: fquiz-celery-beat command: - celery - -A - app.core.celery_app.celery_app - beat - --loglevel=${CELERY_LOG_LEVEL:-INFO} - --schedule=/tmp/celerybeat-schedule depends_on: api: condition: service_healthy redis: condition: service_healthy environment: DATABASE_URL: ${DATABASE_URL:-} DB_HOST: ${DB_HOST:-db} DB_PORT: ${DB_PORT:-5432} DB_NAME: ${DB_NAME:-postgres} DB_SCHEMA: ${DB_SCHEMA:-public} DB_USERNAME: ${DB_USERNAME:-fquiz} DB_PASSWORD: ${DB_PASSWORD:-fquiz} CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1} CELERY_TIMEZONE: ${CELERY_TIMEZONE:-Asia/Shanghai} SCHEDULER_EXPIRE_INTERVAL_SECONDS: ${SCHEDULER_EXPIRE_INTERVAL_SECONDS:-60} restart: unless-stopped web: image: ${WEB_IMAGE} container_name: fquiz-web depends_on: api: condition: service_healthy environment: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000} NODE_ENV: production ports: - "3000:3000" restart: unless-stopped volumes: fquiz_db_data: fquiz_redis_data: fquiz_minio_data: YAML if [ ! -f .env ]; then cat > .env <<'ENV' NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 API_HOST=0.0.0.0 API_PORT=8000 API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 API_CORS_ORIGIN_REGEX= DATABASE_URL= DB_HOST=db DB_PORT=5432 DB_NAME=postgres DB_SCHEMA=public DB_USERNAME=fquiz DB_PASSWORD=fquiz FILE_VFS_ROOT=./data/vfs MINIO_ENABLED=true MINIO_ENDPOINT=http://minio:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=fquiz-files MINIO_REGION=us-east-1 JWT_SECRET_KEY=change-this-in-production ACCESS_TOKEN_EXPIRE_MINUTES=480 REFRESH_TOKEN_EXPIRE_DAYS=30 REFRESH_COOKIE_SECURE=false REFRESH_COOKIE_SAMESITE=lax LLM_PROVIDER_API_KEYS= LLM_REQUEST_TIMEOUT_SECONDS=60 CHAT_CONTEXT_MESSAGE_LIMIT=12 CHAT_DEFAULT_SYSTEM_PROMPT=You are a helpful assistant. CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/1 CELERY_TIMEZONE=Asia/Shanghai SCHEDULER_EXPIRE_INTERVAL_SECONDS=60 WINE_BINARY_PATH=wine WINE_ALLOWED_ROOT=./data/wine WINE_DEFAULT_TIMEOUT_SECONDS=300 WINE_MAX_TIMEOUT_SECONDS=1800 INITIAL_ADMIN_EMAIL=admin@example.com INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=change-me-strong-password POSTGRES_DB=fquiz POSTGRES_USER=fquiz POSTGRES_PASSWORD=fquiz POSTGRES_PORT=5434 POSTGRES_IMAGE=docker.m.daocloud.io/pgvector/pgvector:pg16 REDIS_IMAGE=docker.m.daocloud.io/library/redis:7-alpine REDIS_PORT=6379 MINIO_IMAGE=minio/minio:latest MINIO_MC_IMAGE=minio/mc:latest MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 ENV echo "[warn] .env 不存在,已写入默认模板,请尽快改成生产配置。" fi cat > .images.env </dev/null 2>&1; then COMPOSE_CMD="docker-compose" fi pull_with_retry() { local max_retries=3 local attempt=1 while true; do if ${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml pull; then break fi if [ "${attempt}" -ge "${max_retries}" ]; then echo "[error] docker compose pull failed after ${max_retries} attempts." return 1 fi local sleep_seconds=$((attempt * 20)) echo "[warn] docker compose pull failed (attempt ${attempt}/${max_retries}), retrying in ${sleep_seconds}s..." sleep "${sleep_seconds}" attempt=$((attempt + 1)) done } pull_with_retry ${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml up -d --remove-orphans ${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml ps