From d0eb006ffe085dfbc891a164113785543c970e2d Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 15:33:43 +0800 Subject: [PATCH 1/7] Split Drone CI and deployment compose --- .github/drone.yml | 107 +++++++++++++++++++++++++++--------- docker-compose.cd.yml | 122 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 docker-compose.cd.yml diff --git a/.github/drone.yml b/.github/drone.yml index 4d0fd8ddf..c6f6998f4 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -1,6 +1,6 @@ # ============================================================ # Clawith CI/CD Pipeline -# 基于 Drone CI 的自动化构建、本地部署测试和升级测试 +# 基于 Drone CI 的自动化构建、迁移测试、部署测试和发布 # # 流程: # 1. 代码克隆 (获取完整历史和 tags) @@ -8,7 +8,7 @@ # 3. 空数据库迁移测试 # 4. 本地部署测试 (直接利用本地 docker socket 和 docker-compose.ci.yml) # 5. 本地升级测试 (测试旧版到新版的迁移) -# 6. 打包传输到服务器 (可选) +# 6. tag 构建通过后,打包镜像并部署到服务器 # ============================================================ kind: pipeline type: docker @@ -190,8 +190,8 @@ steps: - .github/scripts/ci_upgrade_test.sh # -------------------------------------------------------- - # Step 6: (可选) 导出镜像并传输到私有服务器 - # 只有在部署测试通过后才会执行到这一步 + # Step 6: CD - 导出镜像并部署到私有服务器 + # 仅 tag 事件执行,且必须等待前面的 CI 测试全部通过 # -------------------------------------------------------- - name: save-images image: docker:24.0.6 @@ -205,14 +205,12 @@ steps: COMMIT_SHORT="$(printf '%.8s' "$DRONE_COMMIT")" docker save -o clawith-backend-new.tar "clawith-backend:ci-$DRONE_BUILD_NUMBER-$COMMIT_SHORT" docker save -o clawith-frontend-new.tar "clawith-frontend:ci-$DRONE_BUILD_NUMBER-$COMMIT_SHORT" - chmod 644 clawith-backend-new.tar clawith-frontend-new.tar - ls -lh clawith-backend-new.tar clawith-frontend-new.tar + printf '%s\n' "ci-$DRONE_BUILD_NUMBER-$COMMIT_SHORT" > image-tag.txt + chmod 644 clawith-backend-new.tar clawith-frontend-new.tar image-tag.txt + ls -lh clawith-backend-new.tar clawith-frontend-new.tar image-tag.txt when: - branch: - - main - - release event: - - push + - tag - name: scp-images image: appleboy/drone-scp @@ -220,23 +218,81 @@ steps: settings: host: from_secret: PRIVATE_SERVER_IP - username: root + username: qinrui password: from_secret: sshpwd - port: 22 + port: 10022 command_timeout: 15m - target: /opt/server/clawith-ci/ + target: /home/qinrui/clawith_new source: - clawith-backend-new.tar - clawith-frontend-new.tar - - docker-compose.ci.yml + - docker-compose.cd.yml + - image-tag.txt rm: false + overwrite: true when: - branch: - - main - - release event: - - push + - tag + + - name: restart-services + image: appleboy/drone-ssh + pull: if-not-exists + settings: + host: + from_secret: PRIVATE_SERVER_IP + username: qinrui + password: + from_secret: sshpwd + port: 10022 + command_timeout: 15m + script_stop: true + script: + - set -eu + - cd /home/qinrui/clawith_new + - IMAGE_TAG="$(cat image-tag.txt)" + - | + case "$IMAGE_TAG" in + ci-[0-9]*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; + *) + echo "非法镜像标签: $IMAGE_TAG" >&2 + exit 1 + ;; + esac + - test -f .env + - test -f ss-nodes.json + - test -f nginx/default.conf + - | + IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml \ + config --quiet + - docker load -i clawith-backend-new.tar + - docker load -i clawith-frontend-new.tar + # PostgreSQL 和 Redis 不参与重建,避免发布过程影响持久化服务。 + - | + IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml \ + up -d --no-deps --force-recreate \ + backend-api backend-worker frontend + - | + ATTEMPT=0 + until IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml \ + exec -T backend-api \ + curl -fsS --max-time 5 http://frontend:3000/api/health | + grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; do + ATTEMPT=$((ATTEMPT + 1)) + if [ "$ATTEMPT" -ge 24 ]; then + echo "部署后健康检查超时" >&2 + IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml ps >&2 + IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml \ + logs --tail=200 backend-api backend-worker frontend >&2 + exit 1 + fi + sleep 5 + done + - IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml ps + - echo "服务重启完成" + when: + event: + - tag - name: cleanup-ci-images image: docker:24.0.6 @@ -257,15 +313,18 @@ steps: - failure trigger: - branch: - - main - - release - - ci/test-drone # 临时添加,测试完成后删除 - - ci/test-drone-3010 # 本次 3010 隔离与升级流程验证 - - feature/unified-chat-directory-pr760-regression event: - push - pull_request + - tag + ref: + - refs/heads/main + - refs/heads/release + - refs/heads/ci/test-drone + - refs/heads/ci/test-drone-3010 + - refs/heads/feature/unified-chat-directory-pr760-regression + - refs/pull/** + - refs/tags/** volumes: - name: docker-socket diff --git a/docker-compose.cd.yml b/docker-compose.cd.yml new file mode 100644 index 000000000..0980e93cd --- /dev/null +++ b/docker-compose.cd.yml @@ -0,0 +1,122 @@ +# ============================================================ +# CD 专用 Docker Compose +# 仅更新应用容器;PostgreSQL、Redis、MinIO 由服务器现有环境管理。 +# 服务器必须预先创建 CLAWITH_DOCKER_NETWORK 指定的网络,并提供 .env、 +# ss-nodes.json 和 nginx/default.conf。 +# ============================================================ + +services: + backend-api: + image: clawith-backend:${IMAGE_TAG:?IMAGE_TAG is required} + restart: unless-stopped + command: ["/bin/bash", "/app/entrypoint.sh"] + environment: + DATABASE_URL: postgresql+asyncpg://clawith:clawith@postgres:5432/clawith + REDIS_URL: redis://redis:6379/0 + AGENT_DATA_DIR: /data/agents + AGENT_TEMPLATE_DIR: /app/agent_template + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY is required} + PROCESS_ROLE: api + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} + PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: ${PASSWORD_RESET_TOKEN_EXPIRE_MINUTES:-30} + AGENT_RUNTIME_COMMAND_CONCURRENCY: ${AGENT_RUNTIME_COMMAND_CONCURRENCY:-10} + AGENT_RUNTIME_V2_ENABLED: "true" + AGENT_RUNTIME_V2_AGENT_IDS: "" + AGENT_RUNTIME_V2_SOURCE_TYPES: "" + DOCKER_NETWORK: ${CLAWITH_DOCKER_NETWORK:-clawith_network} + CORS_ORIGINS: '["*"]' + STORAGE_BACKEND: ${STORAGE_BACKEND:-local} + STORAGE_LOCAL_ROOT: /data/agents + STORAGE_LOCAL_FALLBACK_ENABLED: ${STORAGE_LOCAL_FALLBACK_ENABLED:-true} + S3_BUCKET: ${S3_BUCKET:-clawith} + S3_REGION: ${S3_REGION:-us-east-1} + S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-${MINIO_ROOT_USER:-clawith}} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-${MINIO_ROOT_PASSWORD:-clawith-minio-secret}} + S3_PREFIX: ${S3_PREFIX:-agents} + volumes: + - /data/agent_data:/data/agents + - /var/run/docker.sock:/var/run/docker.sock + - ./ss-nodes.json:/data/ss-nodes.json:ro + privileged: true + cap_add: + - SYS_ADMIN + security_opt: + - seccomp=unconfined + networks: + default: + aliases: + - backend + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + backend-worker: + image: clawith-backend:${IMAGE_TAG:?IMAGE_TAG is required} + restart: unless-stopped + command: ["/bin/bash", "/app/entrypoint.sh"] + environment: + DATABASE_URL: postgresql+asyncpg://clawith:clawith@postgres:5432/clawith + REDIS_URL: redis://redis:6379/0 + AGENT_DATA_DIR: /data/agents + AGENT_TEMPLATE_DIR: /app/agent_template + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY is required} + PROCESS_ROLE: bootstrap,worker,connector + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} + PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: ${PASSWORD_RESET_TOKEN_EXPIRE_MINUTES:-30} + AGENT_RUNTIME_COMMAND_CONCURRENCY: ${AGENT_RUNTIME_COMMAND_CONCURRENCY:-10} + AGENT_RUNTIME_V2_ENABLED: "true" + AGENT_RUNTIME_V2_AGENT_IDS: "" + AGENT_RUNTIME_V2_SOURCE_TYPES: "" + DOCKER_NETWORK: ${CLAWITH_DOCKER_NETWORK:-clawith_network} + CORS_ORIGINS: '["*"]' + STORAGE_BACKEND: ${STORAGE_BACKEND:-local} + STORAGE_LOCAL_ROOT: /data/agents + STORAGE_LOCAL_FALLBACK_ENABLED: ${STORAGE_LOCAL_FALLBACK_ENABLED:-true} + S3_BUCKET: ${S3_BUCKET:-clawith} + S3_REGION: ${S3_REGION:-us-east-1} + S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-${MINIO_ROOT_USER:-clawith}} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-${MINIO_ROOT_PASSWORD:-clawith-minio-secret}} + S3_PREFIX: ${S3_PREFIX:-agents} + volumes: + - /data/agent_data:/data/agents + - /var/run/docker.sock:/var/run/docker.sock + - ./ss-nodes.json:/data/ss-nodes.json:ro + privileged: true + cap_add: + - SYS_ADMIN + security_opt: + - seccomp=unconfined + networks: + - default + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + frontend: + image: clawith-frontend:${IMAGE_TAG:?IMAGE_TAG is required} + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3008}:3000" + environment: + VITE_API_URL: http://localhost:8000 + API_UPSTREAM: ${API_UPSTREAM:-backend:8000} + MINIO_UPSTREAM: ${MINIO_UPSTREAM:-minio:9000} + volumes: + - ./nginx/default.conf:/etc/nginx/templates/default.conf.template:ro + networks: + - default + depends_on: + - backend-api + +networks: + default: + external: true + name: ${CLAWITH_DOCKER_NETWORK:-clawith_network} From f6bfa1947cec1c75ddc4d6c7e783a78184807cca Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 15:43:11 +0800 Subject: [PATCH 2/7] Keep Drone SSH control flow intact --- .github/drone.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/drone.yml b/.github/drone.yml index c6f6998f4..81e74b4f8 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -246,7 +246,6 @@ steps: from_secret: sshpwd port: 10022 command_timeout: 15m - script_stop: true script: - set -eu - cd /home/qinrui/clawith_new From 305b64d219fe1552bec1597bb8d1d6cfce20305b Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 15:51:55 +0800 Subject: [PATCH 3/7] Report missing Drone deployment files --- .github/drone.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/drone.yml b/.github/drone.yml index 81e74b4f8..3985c0485 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -258,9 +258,13 @@ steps: exit 1 ;; esac - - test -f .env - - test -f ss-nodes.json - - test -f nginx/default.conf + - | + for REQUIRED_FILE in .env ss-nodes.json nginx/default.conf; do + if [ ! -f "$REQUIRED_FILE" ]; then + echo "服务器缺少部署文件: $REQUIRED_FILE" >&2 + exit 1 + fi + done - | IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml \ config --quiet From 7d16849984ec1f668e264d26d628d9639f0ecef3 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 16:01:13 +0800 Subject: [PATCH 4/7] Allow deployment without optional SS nodes --- .github/drone.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/drone.yml b/.github/drone.yml index 3985c0485..9628467e4 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -259,12 +259,17 @@ steps: ;; esac - | - for REQUIRED_FILE in .env ss-nodes.json nginx/default.conf; do + for REQUIRED_FILE in .env nginx/default.conf; do if [ ! -f "$REQUIRED_FILE" ]; then echo "服务器缺少部署文件: $REQUIRED_FILE" >&2 exit 1 fi done + - | + if [ ! -f ss-nodes.json ]; then + printf '[]\n' > ss-nodes.json + echo "未配置 SS 节点,已创建空的 ss-nodes.json" + fi - | IMAGE_TAG="$IMAGE_TAG" docker compose -f docker-compose.cd.yml \ config --quiet From 2d9465f2e11cb2bd7c751d0b2b483391c1e25411 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 16:12:02 +0800 Subject: [PATCH 5/7] Repair optional SS config mount path --- .github/drone.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/drone.yml b/.github/drone.yml index 9628467e4..6349b3220 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -266,6 +266,10 @@ steps: fi done - | + if [ -d ss-nodes.json ]; then + rmdir ss-nodes.json + echo "已清理 Docker 自动创建的空目录 ss-nodes.json" + fi if [ ! -f ss-nodes.json ]; then printf '[]\n' > ss-nodes.json echo "未配置 SS 节点,已创建空的 ss-nodes.json" From 9f8d65c5fdf2e6ffc3636158b9848eb2302feba7 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 18:34:25 +0800 Subject: [PATCH 6/7] Hand release deployment off to Drone --- .github/drone.yml | 2 +- .github/scripts/deploy-release.sh | 155 ------------------------------ .github/workflows/release.yml | 145 ++++++++++++++-------------- deploy/RELEASE_DEPLOYMENT.md | 137 +++++++++++++------------- 4 files changed, 140 insertions(+), 299 deletions(-) delete mode 100755 .github/scripts/deploy-release.sh diff --git a/.github/drone.yml b/.github/drone.yml index 6349b3220..0e3e83e62 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -336,7 +336,7 @@ trigger: - refs/heads/ci/test-drone-3010 - refs/heads/feature/unified-chat-directory-pr760-regression - refs/pull/** - - refs/tags/** + - refs/tags/v* volumes: - name: docker-socket diff --git a/.github/scripts/deploy-release.sh b/.github/scripts/deploy-release.sh deleted file mode 100755 index d4205b2b9..000000000 --- a/.github/scripts/deploy-release.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -release_tag=${1:?release tag is required} -deploy_dir=${2:?deployment directory is required} -source_dir=${3:?source directory is required} -pip_index_url=${4:-https://mirrors.aliyun.com/pypi/simple/} -pip_trusted_host=${5:-} - -case "$deploy_dir" in - /*) ;; - *) deploy_dir="$PWD/$deploy_dir" ;; -esac -case "$source_dir" in - /*) ;; - *) source_dir="$PWD/$source_dir" ;; -esac - -if ! [[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then - echo "Invalid release tag: $release_tag" >&2 - exit 1 -fi - -cd "$source_dir" -git rev-parse --is-inside-work-tree >/dev/null - -if [ -n "$(git status --porcelain --untracked-files=no)" ]; then - echo "Source contains tracked local changes; refusing to overwrite production." >&2 - git status --short --untracked-files=no >&2 - exit 1 -fi - -if docker version >/dev/null 2>&1; then - docker_cmd=(docker) -elif sudo -n docker version >/dev/null 2>&1; then - docker_cmd=(sudo -n docker) -else - echo "Docker is unavailable to the deployment user." >&2 - exit 1 -fi -compose=("${docker_cmd[@]}" compose) -app_services=(backend-api backend-worker frontend) - -cd "$deploy_dir" -test -f .env -"${compose[@]}" config --quiet - -backend_container=$("${compose[@]}" ps -q backend-api) -worker_container=$("${compose[@]}" ps -q backend-worker) -frontend_container=$("${compose[@]}" ps -q frontend) -test -n "$backend_container" -test -n "$worker_container" -test -n "$frontend_container" - -old_backend_image=$("${docker_cmd[@]}" inspect -f '{{.Image}}' "$backend_container") -old_worker_image=$("${docker_cmd[@]}" inspect -f '{{.Image}}' "$worker_container") -old_frontend_image=$("${docker_cmd[@]}" inspect -f '{{.Image}}' "$frontend_container") -if [ "$old_backend_image" != "$old_worker_image" ]; then - echo "Backend API and worker do not use the same image; refusing deployment." >&2 - exit 1 -fi - -cd "$source_dir" -previous_commit=$(git rev-parse HEAD) -activation_started=false - -rollback() { - status=$? - trap - EXIT - if [ "$status" -ne 0 ]; then - echo "Deployment failed; restoring source $previous_commit." >&2 - set +e - cd "$source_dir" - git checkout --detach "$previous_commit" - if [ "$activation_started" = true ]; then - "${docker_cmd[@]}" tag "$old_backend_image" clawith-backend:latest - "${docker_cmd[@]}" tag "$old_frontend_image" clawith-frontend:latest - cd "$deploy_dir" - "${compose[@]}" up -d --no-deps --force-recreate "${app_services[@]}" - fi - fi - exit "$status" -} -trap rollback EXIT - -git fetch origin "refs/heads/main:refs/remotes/origin/main" -git fetch origin "refs/tags/$release_tag:refs/tags/$release_tag" - -release_commit=$(git rev-parse "$release_tag^{commit}") -if ! git merge-base --is-ancestor "$release_commit" origin/main; then - echo "Release $release_tag is not contained in origin/main." >&2 - exit 1 -fi - -git checkout --detach "$release_tag" - -backend_release_image="clawith-backend:$release_tag" -frontend_release_image="clawith-frontend:$release_tag" - -"${docker_cmd[@]}" build \ - --build-arg "CLAWITH_PIP_INDEX_URL=$pip_index_url" \ - --build-arg "CLAWITH_PIP_TRUSTED_HOST=$pip_trusted_host" \ - -t "$backend_release_image" \ - backend -"${docker_cmd[@]}" build -t "$frontend_release_image" frontend - -activation_started=true -"${docker_cmd[@]}" tag "$backend_release_image" clawith-backend:latest -"${docker_cmd[@]}" tag "$frontend_release_image" clawith-frontend:latest - -cd "$deploy_dir" -"${compose[@]}" up -d --no-deps --force-recreate "${app_services[@]}" - -expected_backend_id=$("${docker_cmd[@]}" image inspect -f '{{.Id}}' "$backend_release_image") -expected_frontend_id=$("${docker_cmd[@]}" image inspect -f '{{.Id}}' "$frontend_release_image") -running_api_id=$("${docker_cmd[@]}" inspect -f '{{.Image}}' "$("${compose[@]}" ps -q backend-api)") -running_worker_id=$("${docker_cmd[@]}" inspect -f '{{.Image}}' "$("${compose[@]}" ps -q backend-worker)") -running_frontend_id=$("${docker_cmd[@]}" inspect -f '{{.Image}}' "$("${compose[@]}" ps -q frontend)") - -if [ "$running_api_id" != "$expected_backend_id" ] || - [ "$running_worker_id" != "$expected_backend_id" ] || - [ "$running_frontend_id" != "$expected_frontend_id" ]; then - echo "Running containers do not use the newly built release images." >&2 - exit 1 -fi - -published_address=$("${compose[@]}" port frontend 3000 | tail -n 1) -frontend_port=${published_address##*:} -if ! [[ "$frontend_port" =~ ^[0-9]+$ ]]; then - echo "Unable to resolve the published frontend port." >&2 - exit 1 -fi - -expected_version=${release_tag#v} -health_response= -for attempt in $(seq 1 24); do - if health_response=$(curl -fsS --max-time 5 "http://127.0.0.1:$frontend_port/api/health"); then - health_status=$(printf '%s' "$health_response" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') - running_version=$(printf '%s' "$health_response" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') - if [ "$health_status" = ok ] && [ "$running_version" = "$expected_version" ]; then - echo "Successfully deployed $release_tag ($release_commit)." - "${compose[@]}" ps - activation_started=false - exit 0 - fi - fi - sleep 5 -done - -echo "Production health check failed after 120 seconds." >&2 -echo "Last health response: ${health_response:-}" >&2 -"${compose[@]}" ps >&2 -"${compose[@]}" logs --tail 200 backend frontend >&2 -exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c06d09b89..59cf24c9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ on: permissions: contents: write pull-requests: write + statuses: read concurrency: group: release-${{ github.ref_name }} @@ -461,11 +462,12 @@ jobs: id: release_info shell: bash env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_BODY: ${{ github.event.pull_request.body }} run: | set -euo pipefail - branch_name="${{ github.event.pull_request.head.ref }}" + branch_name="$HEAD_REF" tag_name="${branch_name#release/}" python <<'PY' @@ -488,88 +490,83 @@ jobs: run: | set -euo pipefail - git tag -a "$TARGET_TAG" -m "Release $TARGET_TAG" - git push origin "$TARGET_TAG" - - - name: Publish GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.release_info.outputs.tag }} - name: ${{ steps.release_info.outputs.tag }} - body_path: release-notes.extracted.md - prerelease: ${{ github.event.pull_request.draft }} - generate_release_notes: false - - deploy_release: - name: Deploy release to production - needs: publish_release - if: ${{ needs.publish_release.result == 'success' }} - runs-on: ubuntu-latest - environment: production - env: - DEPLOY_HOST: ${{ vars.CLAWITH_DEPLOY_HOST }} - DEPLOY_USER: ${{ vars.CLAWITH_DEPLOY_USER }} - DEPLOY_PORT: ${{ vars.CLAWITH_DEPLOY_PORT || '10022' }} - DEPLOY_PATH: ${{ vars.CLAWITH_DEPLOY_PATH || 'clawith_new' }} - SOURCE_PATH: ${{ vars.CLAWITH_SOURCE_PATH || 'Clawith' }} - PIP_INDEX_URL: ${{ vars.CLAWITH_PIP_INDEX_URL || 'https://mirrors.aliyun.com/pypi/simple/' }} - PIP_TRUSTED_HOST: ${{ vars.CLAWITH_PIP_TRUSTED_HOST }} - RELEASE_TAG: ${{ needs.publish_release.outputs.tag }} - SSH_PRIVATE_KEY: ${{ secrets.CLAWITH_DEPLOY_SSH_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.CLAWITH_DEPLOY_KNOWN_HOSTS }} - steps: - - name: Checkout deployment tooling - uses: actions/checkout@v7 - - - name: Validate deployment configuration - shell: bash - run: | - set -euo pipefail + release_commit="$(git rev-parse HEAD)" + git fetch --force --tags - required=( - DEPLOY_HOST DEPLOY_USER DEPLOY_PORT DEPLOY_PATH SOURCE_PATH RELEASE_TAG - SSH_PRIVATE_KEY SSH_KNOWN_HOSTS - ) - for name in "${required[@]}"; do - if [ -z "${!name}" ]; then - echo "::error::Missing required production deployment setting: $name" + if existing_commit="$(git rev-parse "$TARGET_TAG^{commit}" 2>/dev/null)"; then + if [ "$existing_commit" != "$release_commit" ]; then + echo "::error::Tag $TARGET_TAG already points to $existing_commit, expected $release_commit" exit 1 fi - done - - if ! [[ "$DEPLOY_PORT" =~ ^[0-9]+$ ]] || [ "$DEPLOY_PORT" -lt 1 ] || [ "$DEPLOY_PORT" -gt 65535 ]; then - echo "::error::CLAWITH_DEPLOY_PORT must be a valid TCP port" - exit 1 + echo "Tag $TARGET_TAG already points to $release_commit; reusing it." + else + git tag -a "$TARGET_TAG" -m "Release $TARGET_TAG" + git push origin "$TARGET_TAG" fi - - name: Configure SSH + - name: Wait for Drone deployment shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_TAG: ${{ steps.release_info.outputs.tag }} + DRONE_STATUS_CONTEXT: continuous-integration/drone/tag run: | set -euo pipefail - install -d -m 0700 "$HOME/.ssh" - printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" - chmod 0600 "$HOME/.ssh/known_hosts" + release_commit="$(git rev-list -n 1 "$TARGET_TAG")" + echo "Waiting for Drone deployment of $TARGET_TAG ($release_commit)" - key_file="$RUNNER_TEMP/clawith-deploy-key" - printf '%s\n' "$SSH_PRIVATE_KEY" > "$key_file" - chmod 0600 "$key_file" - echo "SSH_KEY_FILE=$key_file" >> "$GITHUB_ENV" + for attempt in $(seq 1 180); do + status_payload="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${release_commit}/status" + )" - - name: Update, build, and restart production - shell: bash - run: | - set -euo pipefail + read -r drone_state drone_url drone_description < <( + STATUS_PAYLOAD="$status_payload" python3 <<'PY' + import json + import os + + payload = json.loads(os.environ["STATUS_PAYLOAD"]) + context = os.environ["DRONE_STATUS_CONTEXT"] + status = next( + (item for item in payload.get("statuses", []) if item.get("context") == context), + {}, + ) + values = ( + status.get("state", "waiting"), + status.get("target_url", "-"), + status.get("description", "Drone has not reported a status yet"), + ) + print(*(str(value).replace("\n", " ") for value in values)) + PY + ) + + echo "Drone status: state=$drone_state description=$drone_description url=$drone_url" + + case "$drone_state" in + success) + echo "Drone deployed $TARGET_TAG successfully." + exit 0 + ;; + error|failure) + echo "::error::Drone deployment failed: $drone_url" + exit 1 + ;; + esac + + sleep 10 + done + + echo "::error::Timed out after 30 minutes waiting for Drone deployment" + exit 1 - printf -v remote_command 'bash -s -- %q %q %q %q %q' \ - "$RELEASE_TAG" "$DEPLOY_PATH" "$SOURCE_PATH" \ - "$PIP_INDEX_URL" "$PIP_TRUSTED_HOST" - ssh \ - -i "$SSH_KEY_FILE" \ - -p "$DEPLOY_PORT" \ - -o BatchMode=yes \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ - "$DEPLOY_USER@$DEPLOY_HOST" \ - "$remote_command" \ - < .github/scripts/deploy-release.sh + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.release_info.outputs.tag }} + name: ${{ steps.release_info.outputs.tag }} + body_path: release-notes.extracted.md + prerelease: ${{ github.event.pull_request.draft }} + generate_release_notes: false diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md index 5903c4936..280270cec 100644 --- a/deploy/RELEASE_DEPLOYMENT.md +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -1,81 +1,80 @@ # Production release deployment -Merging an automated `release/vX.Y.Z` pull request publishes the GitHub Release -and then deploys that exact tag to production. GitHub Actions does not build or -upload application artifacts. The production server fetches the tag in its -source checkout, builds versioned backend and frontend images, points the -deployment's `latest` image tags to them, and restarts the existing Compose -project. Success requires the API and worker containers to use the newly built -backend image, the frontend container to use the newly built frontend image, -and `/api/health` to report both `status: ok` and the released version. +Production releases are proposed and published by GitHub Actions, while Drone +owns CI validation, artifact transfer, and production deployment. + +## Release flow + +1. Manually run the `Release` GitHub Actions workflow. +2. GitHub Actions calculates the next version, updates the version files, drafts + release notes, and opens a `release/vX.Y.Z` pull request. +3. Merging that pull request creates and pushes the annotated `vX.Y.Z` tag. +4. Drone receives the tag webhook and runs the complete pipeline: + - build the previous and target backend/frontend images; + - validate fresh-database migrations; + - validate a fresh application deployment; + - validate upgrading from the previous stable release; + - export and transfer the target images; + - load the images and recreate the production application services; + - verify the proxied API health endpoint. +5. GitHub Actions waits for the + `continuous-integration/drone/tag` commit status. It publishes the GitHub + Release only after Drone reports success. + +Only tags matching `refs/tags/v*` enter the Drone release pipeline. Branch +pushes and pull requests still run CI, but never export or deploy images. + +## Drone configuration + +The repository must be trusted by Drone because the CI steps use privileged +containers and the host Docker socket. + +Configure these Drone repository secrets: + +| Name | Purpose | +| --- | --- | +| `PROXY` | Optional HTTP/HTTPS proxy used during clone and image builds | +| `PRIVATE_SERVER_IP` | Production server hostname or IP address | +| `sshpwd` | Password for the production deployment user | -The deployment keeps the server's ignored `.env`, `ss-nodes.json`, -`backend/agent_data`, and Docker volumes in place. It refuses to overwrite -tracked local changes. If the build, restart, or health check fails after the -checkout, it checks out the previous commit and rebuilds the previous version. +The deployment currently connects as `qinrui` on port `10022` and writes +release artifacts to `/home/qinrui/clawith_new`. -## GitHub production configuration +GitHub Actions no longer requires the production SSH key, known-hosts entry, or +the former `CLAWITH_DEPLOY_*` production environment variables. -Configure these under **Settings > Environments > production**. Environment -protection rules can require approval before the deployment job starts. +## Server prerequisites -### Variables +The production server must provide: -| Name | Current production value | -| --- | --- | -| `CLAWITH_DEPLOY_HOST` | `82.156.53.84` | -| `CLAWITH_DEPLOY_USER` | `qinrui` | -| `CLAWITH_DEPLOY_PORT` | `10022` | -| `CLAWITH_DEPLOY_PATH` | `clawith_new` | -| `CLAWITH_SOURCE_PATH` | `Clawith` | -| `CLAWITH_PIP_INDEX_URL` | `https://mirrors.aliyun.com/pypi/simple/` (optional default) | -| `CLAWITH_PIP_TRUSTED_HOST` | Optional; only for a mirror that requires pip trusted-host handling | +- Docker with the Compose plugin; +- permission for `qinrui` to use Docker; +- `/home/qinrui/clawith_new/.env`; +- `/home/qinrui/clawith_new/nginx/default.conf`; +- the external Docker network named by + `CLAWITH_DOCKER_NETWORK` (default: `clawith_network`); +- existing PostgreSQL, Redis, and MinIO services reachable on that network as + `postgres`, `redis`, and `minio`; +- `/data/agent_data` for persistent agent data. -### Secrets +`ss-nodes.json` is optional. If it is absent, Drone creates a safe empty JSON +array and the application starts without the optional SS/Discord proxy. An +existing real configuration is preserved. -| Name | Value | -| --- | --- | -| `CLAWITH_DEPLOY_SSH_KEY` | Private key for a dedicated deployment identity | -| `CLAWITH_DEPLOY_KNOWN_HOSTS` | Verified OpenSSH `known_hosts` entry for the production server | +## Deployment behavior -The SSH key must be authorized for the deployment user and should be dedicated -to GitHub Actions. Verify the server fingerprint through a trusted channel -before storing the `known_hosts` entry; do not blindly trust an `ssh-keyscan` -result. +Drone uploads: -## Server prerequisites +- `clawith-backend-new.tar`; +- `clawith-frontend-new.tar`; +- `docker-compose.cd.yml`; +- `image-tag.txt`. + +The remote deployment loads the transferred images and force-recreates only +`backend-api`, `backend-worker`, and `frontend`. PostgreSQL, Redis, and MinIO +are not recreated. Deployment succeeds only after the frontend proxy returns an +API health response whose status is `ok`. -The source directory must be a Git checkout whose `origin` points to this -repository. The deployment directory keeps the production `.env`, Compose -file, Nginx configuration, and other environment-specific files. The server -also needs: - -- Docker with the Compose plugin -- `curl` -- read access to the repository -- permission for the deployment user to run Docker directly or with - passwordless `sudo` -- an existing `.env` in `clawith_new` - -Validate the server once before enabling automatic deployments: - -```bash -ssh clawith -cd Clawith -git status --short -git remote -v -cd ../clawith_new -test -f .env -docker compose config --quiet -docker compose ps -curl -fsS http://127.0.0.1:3008/api/health -``` - -The PyPI mirror defaults to Aliyun and can be overridden with the two optional -production Environment variables above. Docker's existing image-layer cache -continues to skip dependency installation when `backend/pyproject.toml` has not -changed. - -The server checkout is intentionally left at a detached release tag after a -successful deployment. Never move or reuse a published tag; publish a new -version to roll forward. +If Drone fails, the GitHub tag remains for investigation, but the GitHub +Release is not published. Fix or rerun the Drone build for that tag; do not move +or reuse a published release tag. From 520d6d5b2fd3b63c77f36c06aea517a6d568b76e Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 28 Jul 2026 18:55:08 +0800 Subject: [PATCH 7/7] Publish releases without waiting for Drone --- .github/workflows/release.yml | 59 ----------------------------------- deploy/RELEASE_DEPLOYMENT.md | 12 +++---- 2 files changed, 6 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59cf24c9b..1e4dd5576 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,6 @@ on: permissions: contents: write pull-requests: write - statuses: read concurrency: group: release-${{ github.ref_name }} @@ -504,64 +503,6 @@ jobs: git push origin "$TARGET_TAG" fi - - name: Wait for Drone deployment - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TARGET_TAG: ${{ steps.release_info.outputs.tag }} - DRONE_STATUS_CONTEXT: continuous-integration/drone/tag - run: | - set -euo pipefail - - release_commit="$(git rev-list -n 1 "$TARGET_TAG")" - echo "Waiting for Drone deployment of $TARGET_TAG ($release_commit)" - - for attempt in $(seq 1 180); do - status_payload="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${release_commit}/status" - )" - - read -r drone_state drone_url drone_description < <( - STATUS_PAYLOAD="$status_payload" python3 <<'PY' - import json - import os - - payload = json.loads(os.environ["STATUS_PAYLOAD"]) - context = os.environ["DRONE_STATUS_CONTEXT"] - status = next( - (item for item in payload.get("statuses", []) if item.get("context") == context), - {}, - ) - values = ( - status.get("state", "waiting"), - status.get("target_url", "-"), - status.get("description", "Drone has not reported a status yet"), - ) - print(*(str(value).replace("\n", " ") for value in values)) - PY - ) - - echo "Drone status: state=$drone_state description=$drone_description url=$drone_url" - - case "$drone_state" in - success) - echo "Drone deployed $TARGET_TAG successfully." - exit 0 - ;; - error|failure) - echo "::error::Drone deployment failed: $drone_url" - exit 1 - ;; - esac - - sleep 10 - done - - echo "::error::Timed out after 30 minutes waiting for Drone deployment" - exit 1 - - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md index 280270cec..4ce28ca55 100644 --- a/deploy/RELEASE_DEPLOYMENT.md +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -17,9 +17,9 @@ owns CI validation, artifact transfer, and production deployment. - export and transfer the target images; - load the images and recreate the production application services; - verify the proxied API health endpoint. -5. GitHub Actions waits for the - `continuous-integration/drone/tag` commit status. It publishes the GitHub - Release only after Drone reports success. +5. GitHub Actions publishes the GitHub Release and finishes without waiting for + Drone. Drone continues the deployment asynchronously and reports its status + on the tagged commit. Only tags matching `refs/tags/v*` enter the Drone release pipeline. Branch pushes and pull requests still run CI, but never export or deploy images. @@ -75,6 +75,6 @@ The remote deployment loads the transferred images and force-recreates only are not recreated. Deployment succeeds only after the frontend proxy returns an API health response whose status is `ok`. -If Drone fails, the GitHub tag remains for investigation, but the GitHub -Release is not published. Fix or rerun the Drone build for that tag; do not move -or reuse a published release tag. +If Drone fails, the GitHub tag and Release remain published for investigation. +Fix or rerun the Drone build for that tag; do not move or reuse a published +release tag.