From 76e70ac52711ff0d3e6daa0451d464516fc711d8 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 00:14:22 +0800 Subject: [PATCH 1/7] Automate production deployment after releases --- .github/scripts/deploy-release.sh | 84 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 90 +++++++++++++++++++++++++------ deploy/RELEASE_DEPLOYMENT.md | 67 +++++++++++++++++++++++ 3 files changed, 226 insertions(+), 15 deletions(-) create mode 100755 .github/scripts/deploy-release.sh create mode 100644 deploy/RELEASE_DEPLOYMENT.md diff --git a/.github/scripts/deploy-release.sh b/.github/scripts/deploy-release.sh new file mode 100755 index 000000000..1415b7f93 --- /dev/null +++ b/.github/scripts/deploy-release.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +release_tag=${1:?release tag is required} +deploy_dir=${2:?deployment directory is required} + +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 "$deploy_dir" +git rev-parse --is-inside-work-tree >/dev/null +test -f .env +test -f ss-nodes.json + +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "Tracked files contain local changes; refusing to overwrite production." >&2 + git status --short --untracked-files=no >&2 + exit 1 +fi + +if docker compose version >/dev/null 2>&1; then + compose=(docker compose) +elif sudo -n docker compose version >/dev/null 2>&1; then + compose=(sudo -n docker compose) +else + echo "Docker Compose is unavailable to the deployment user." >&2 + exit 1 +fi + +previous_commit=$(git rev-parse HEAD) +rollback_required=false + +rollback() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ] && [ "$rollback_required" = true ]; then + echo "Deployment failed; restoring $previous_commit." >&2 + set +e + git checkout --detach "$previous_commit" + "${compose[@]}" up -d --build + 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" +rollback_required=true + +"${compose[@]}" config --quiet +"${compose[@]}" up -d --build + +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 + +for attempt in $(seq 1 24); do + if curl -fsS --max-time 5 "http://127.0.0.1:$frontend_port/api/health" >/dev/null; then + echo "Successfully deployed $release_tag ($release_commit)." + "${compose[@]}" ps + rollback_required=false + exit 0 + fi + sleep 5 +done + +echo "Production health check failed after 120 seconds." >&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 57409061e..63bb76939 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,7 +201,8 @@ jobs: 3. ## Bug Fixes: - List resolved bugs, issues, or stability improvements. 4. ## Upgrade Guide: - - Provide standard deployment instructions for upgrading to this version (e.g., rebuilding frontend, restart commands for Docker/Source/Kubernetes). + - Explain that merging the automated release PR publishes the release and automatically deploys its exact tag to production. + - Production fetches the tag in the existing server checkout, builds from source with Docker Compose, restarts the services, and verifies the health endpoint. - Do NOT include manual database migration commands (such as `alembic upgrade heads`), as database migrations run automatically on application startup. - Mimic the exact formatting, sections, and command blocks shown in the Style Reference below (excluding any manual database migration steps). 5. ## Notes: @@ -314,7 +315,6 @@ jobs: BASE_TAG: ${{ steps.version.outputs.base_tag }} TARGET_VERSION: ${{ steps.version.outputs.version }} TARGET_TAG: ${{ steps.version.outputs.tag }} - SOURCE_REF: ${{ github.ref_name }} run: | set -euo pipefail @@ -329,7 +329,6 @@ jobs: base_tag = os.environ["BASE_TAG"] target_version = os.environ["TARGET_VERSION"] target_tag = os.environ["TARGET_TAG"] - source_ref = os.environ["SOURCE_REF"] entries = [] for line in Path(".github/release-artifacts/commit-table.tsv").read_text(encoding="utf-8").splitlines(): @@ -371,18 +370,7 @@ jobs: "", "## Upgrade Guide", "", - "### Docker Deployment", - "```bash", - f"git pull origin {source_ref}", - "docker compose down && docker compose up -d --build", - "```", - "", - "### Source Deployment", - "```bash", - f"git pull origin {source_ref}", - "cd frontend && npm install && npm run build", - "cd ..", - "```", + "Merging the automated release PR publishes this tag and starts the production deployment. The server fetches the exact release tag, builds the Docker services from source, restarts them, and verifies `/api/health` automatically.", "", "## Notes", f"- Release generated from changes since `{base_tag}`.", @@ -443,6 +431,8 @@ jobs: name: Publish release runs-on: ubuntu-latest if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') + outputs: + tag: ${{ steps.release_info.outputs.tag }} steps: - name: Checkout source uses: actions/checkout@v7 @@ -497,3 +487,73 @@ jobs: 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 + 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' }} + 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 released source + uses: actions/checkout@v7 + with: + ref: ${{ needs.publish_release.outputs.tag }} + + - name: Validate deployment configuration + shell: bash + run: | + set -euo pipefail + + required=( + DEPLOY_HOST DEPLOY_USER DEPLOY_PORT DEPLOY_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" + 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 + fi + + - name: Configure SSH + shell: bash + 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" + + 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" + + - name: Update, build, and restart production + shell: bash + run: | + set -euo pipefail + + printf -v remote_command 'bash -s -- %q %q' "$RELEASE_TAG" "$DEPLOY_PATH" + 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 diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md new file mode 100644 index 000000000..f391f0458 --- /dev/null +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -0,0 +1,67 @@ +# 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 and runs +`docker compose up -d --build` in its existing source checkout. + +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. + +## GitHub production configuration + +Configure these under **Settings > Environments > production**. Environment +protection rules can require approval before the deployment job starts. + +### Variables + +| Name | Current production value | +| --- | --- | +| `CLAWITH_DEPLOY_HOST` | `82.156.53.84` | +| `CLAWITH_DEPLOY_USER` | `qinrui` | +| `CLAWITH_DEPLOY_PORT` | `10022` | +| `CLAWITH_DEPLOY_PATH` | `clawith_new` | + +### Secrets + +| 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 | + +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. + +## Server prerequisites + +The deployment directory must already be a Git checkout whose `origin` points +to this repository. 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` and `ss-nodes.json` in `clawith_new` + +Validate the server once before enabling automatic deployments: + +```bash +ssh clawith +cd clawith_new +git status --short +git remote -v +test -f .env +test -f ss-nodes.json +docker compose config --quiet +docker compose up -d --build +curl -fsS http://127.0.0.1:3008/api/health +``` + +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. From 0639ebddd1820ab438afa99a83be2ae97c939bb3 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 00:16:53 +0800 Subject: [PATCH 2/7] Support redeploying existing release tags --- .github/workflows/release.yml | 48 ++++++++++++++++++++++++++++++++--- deploy/RELEASE_DEPLOYMENT.md | 13 ++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63bb76939..0314e124d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,18 @@ name: Release on: workflow_dispatch: inputs: + operation: + description: Release a new version or redeploy an existing tag + required: true + default: release + type: choice + options: + - release + - deploy + deploy_tag: + description: Existing vX.Y.Z tag to deploy when operation is deploy + required: false + type: string release_type: description: Release type to cut required: true @@ -42,7 +54,7 @@ jobs: propose_release: name: Propose release runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'release' steps: - name: Checkout source @@ -488,9 +500,37 @@ jobs: prerelease: ${{ github.event.pull_request.draft }} generate_release_notes: false + resolve_deploy_tag: + name: Resolve deployment tag + needs: publish_release + if: >- + ${{ always() && + (needs.publish_release.result == 'success' || + (github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy')) }} + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.value }} + steps: + - name: Resolve and validate tag + id: tag + shell: bash + env: + PUBLISHED_TAG: ${{ needs.publish_release.outputs.tag }} + REQUESTED_TAG: ${{ inputs.deploy_tag }} + run: | + set -euo pipefail + + tag="${PUBLISHED_TAG:-$REQUESTED_TAG}" + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then + echo "::error::A valid deploy_tag in vX.Y.Z form is required" + exit 1 + fi + + echo "value=$tag" >> "$GITHUB_OUTPUT" + deploy_release: name: Deploy release to production - needs: publish_release + needs: resolve_deploy_tag runs-on: ubuntu-latest environment: production env: @@ -498,14 +538,14 @@ jobs: DEPLOY_USER: ${{ vars.CLAWITH_DEPLOY_USER }} DEPLOY_PORT: ${{ vars.CLAWITH_DEPLOY_PORT || '10022' }} DEPLOY_PATH: ${{ vars.CLAWITH_DEPLOY_PATH || 'clawith_new' }} - RELEASE_TAG: ${{ needs.publish_release.outputs.tag }} + RELEASE_TAG: ${{ needs.resolve_deploy_tag.outputs.tag }} SSH_PRIVATE_KEY: ${{ secrets.CLAWITH_DEPLOY_SSH_KEY }} SSH_KNOWN_HOSTS: ${{ secrets.CLAWITH_DEPLOY_KNOWN_HOSTS }} steps: - name: Checkout released source uses: actions/checkout@v7 with: - ref: ${{ needs.publish_release.outputs.tag }} + ref: ${{ needs.resolve_deploy_tag.outputs.tag }} - name: Validate deployment configuration shell: bash diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md index f391f0458..1f7417257 100644 --- a/deploy/RELEASE_DEPLOYMENT.md +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -65,3 +65,16 @@ curl -fsS http://127.0.0.1:3008/api/health 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. + +An existing release can be redeployed without creating another release: + +```bash +gh workflow run release.yml \ + --ref main \ + -f operation=deploy \ + -f deploy_tag=v1.11.2 +``` + +The manual path uses the same tag validation, production Environment, SSH +credentials, source build, health check, and rollback behavior as an automatic +post-release deployment. From 85d91a431078e41b6a8844ab3b862497c77c1786 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 00:17:58 +0800 Subject: [PATCH 3/7] Run deployment after manual tag resolution --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0314e124d..65a6561c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -531,6 +531,7 @@ jobs: deploy_release: name: Deploy release to production needs: resolve_deploy_tag + if: ${{ always() && needs.resolve_deploy_tag.result == 'success' }} runs-on: ubuntu-latest environment: production env: From a2a7a47c2f2c2f6d4186e7c9b3d84e0dcaac0375 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 00:19:01 +0800 Subject: [PATCH 4/7] Use current deployment tooling for older tags --- .github/workflows/release.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65a6561c8..19beb2e80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -543,10 +543,8 @@ jobs: SSH_PRIVATE_KEY: ${{ secrets.CLAWITH_DEPLOY_SSH_KEY }} SSH_KNOWN_HOSTS: ${{ secrets.CLAWITH_DEPLOY_KNOWN_HOSTS }} steps: - - name: Checkout released source + - name: Checkout deployment tooling uses: actions/checkout@v7 - with: - ref: ${{ needs.resolve_deploy_tag.outputs.tag }} - name: Validate deployment configuration shell: bash From 27d7ff2658d1c9bfa8018a2135ce4b16bf474ee3 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 10:13:06 +0800 Subject: [PATCH 5/7] Match production source and runtime layout --- .github/scripts/deploy-release.sh | 107 ++++++++++++++++++++++++------ .github/workflows/release.yml | 6 +- deploy/RELEASE_DEPLOYMENT.md | 23 ++++--- 3 files changed, 105 insertions(+), 31 deletions(-) diff --git a/.github/scripts/deploy-release.sh b/.github/scripts/deploy-release.sh index 1415b7f93..a8d36806c 100755 --- a/.github/scripts/deploy-release.sh +++ b/.github/scripts/deploy-release.sh @@ -4,43 +4,79 @@ set -Eeuo pipefail release_tag=${1:?release tag is required} deploy_dir=${2:?deployment directory is required} +source_dir=${3:?source directory is required} + +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 "$deploy_dir" +cd "$source_dir" git rev-parse --is-inside-work-tree >/dev/null -test -f .env -test -f ss-nodes.json if [ -n "$(git status --porcelain --untracked-files=no)" ]; then - echo "Tracked files contain local changes; refusing to overwrite production." >&2 + echo "Source contains tracked local changes; refusing to overwrite production." >&2 git status --short --untracked-files=no >&2 exit 1 fi -if docker compose version >/dev/null 2>&1; then - compose=(docker compose) -elif sudo -n docker compose version >/dev/null 2>&1; then - compose=(sudo -n docker compose) +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 Compose is unavailable to the deployment user." >&2 + 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) -rollback_required=false +activation_started=false rollback() { status=$? trap - EXIT - if [ "$status" -ne 0 ] && [ "$rollback_required" = true ]; then - echo "Deployment failed; restoring $previous_commit." >&2 + if [ "$status" -ne 0 ]; then + echo "Deployment failed; restoring source $previous_commit." >&2 set +e + cd "$source_dir" git checkout --detach "$previous_commit" - "${compose[@]}" up -d --build + 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" } @@ -56,10 +92,32 @@ if ! git merge-base --is-ancestor "$release_commit" origin/main; then fi git checkout --detach "$release_tag" -rollback_required=true -"${compose[@]}" config --quiet -"${compose[@]}" up -d --build +backend_release_image="clawith-backend:$release_tag" +frontend_release_image="clawith-frontend:$release_tag" + +"${docker_cmd[@]}" build -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##*:} @@ -68,17 +126,24 @@ if ! [[ "$frontend_port" =~ ^[0-9]+$ ]]; then exit 1 fi +expected_version=${release_tag#v} +health_response= for attempt in $(seq 1 24); do - if curl -fsS --max-time 5 "http://127.0.0.1:$frontend_port/api/health" >/dev/null; then - echo "Successfully deployed $release_tag ($release_commit)." - "${compose[@]}" ps - rollback_required=false - exit 0 + 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 19beb2e80..09394685f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -539,6 +539,7 @@ jobs: 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' }} RELEASE_TAG: ${{ needs.resolve_deploy_tag.outputs.tag }} SSH_PRIVATE_KEY: ${{ secrets.CLAWITH_DEPLOY_SSH_KEY }} SSH_KNOWN_HOSTS: ${{ secrets.CLAWITH_DEPLOY_KNOWN_HOSTS }} @@ -552,7 +553,7 @@ jobs: set -euo pipefail required=( - DEPLOY_HOST DEPLOY_USER DEPLOY_PORT DEPLOY_PATH RELEASE_TAG + DEPLOY_HOST DEPLOY_USER DEPLOY_PORT DEPLOY_PATH SOURCE_PATH RELEASE_TAG SSH_PRIVATE_KEY SSH_KNOWN_HOSTS ) for name in "${required[@]}"; do @@ -586,7 +587,8 @@ jobs: run: | set -euo pipefail - printf -v remote_command 'bash -s -- %q %q' "$RELEASE_TAG" "$DEPLOY_PATH" + printf -v remote_command 'bash -s -- %q %q %q' \ + "$RELEASE_TAG" "$DEPLOY_PATH" "$SOURCE_PATH" ssh \ -i "$SSH_KEY_FILE" \ -p "$DEPLOY_PORT" \ diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md index 1f7417257..9a2324ba3 100644 --- a/deploy/RELEASE_DEPLOYMENT.md +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -2,8 +2,12 @@ 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 and runs -`docker compose up -d --build` in its existing source checkout. +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. The deployment keeps the server's ignored `.env`, `ss-nodes.json`, `backend/agent_data`, and Docker volumes in place. It refuses to overwrite @@ -23,6 +27,7 @@ protection rules can require approval before the deployment job starts. | `CLAWITH_DEPLOY_USER` | `qinrui` | | `CLAWITH_DEPLOY_PORT` | `10022` | | `CLAWITH_DEPLOY_PATH` | `clawith_new` | +| `CLAWITH_SOURCE_PATH` | `Clawith` | ### Secrets @@ -38,27 +43,29 @@ result. ## Server prerequisites -The deployment directory must already be a Git checkout whose `origin` points -to this repository. The server also needs: +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` and `ss-nodes.json` in `clawith_new` +- an existing `.env` in `clawith_new` Validate the server once before enabling automatic deployments: ```bash ssh clawith -cd clawith_new +cd Clawith git status --short git remote -v +cd ../clawith_new test -f .env -test -f ss-nodes.json docker compose config --quiet -docker compose up -d --build +docker compose ps curl -fsS http://127.0.0.1:3008/api/health ``` From 47dc3f7f2769c22acdb752a494ffa0799a735e5b Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 13:20:05 +0800 Subject: [PATCH 6/7] Simplify release-triggered deployment --- .github/workflows/release.yml | 66 ++++++++++------------------------- deploy/RELEASE_DEPLOYMENT.md | 13 ------- 2 files changed, 19 insertions(+), 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09394685f..6f545ccd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,18 +6,6 @@ name: Release on: workflow_dispatch: inputs: - operation: - description: Release a new version or redeploy an existing tag - required: true - default: release - type: choice - options: - - release - - deploy - deploy_tag: - description: Existing vX.Y.Z tag to deploy when operation is deploy - required: false - type: string release_type: description: Release type to cut required: true @@ -54,7 +42,7 @@ jobs: propose_release: name: Propose release runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' && inputs.operation == 'release' + if: github.event_name == 'workflow_dispatch' steps: - name: Checkout source @@ -213,8 +201,7 @@ jobs: 3. ## Bug Fixes: - List resolved bugs, issues, or stability improvements. 4. ## Upgrade Guide: - - Explain that merging the automated release PR publishes the release and automatically deploys its exact tag to production. - - Production fetches the tag in the existing server checkout, builds from source with Docker Compose, restarts the services, and verifies the health endpoint. + - Provide standard deployment instructions for upgrading to this version (e.g., rebuilding frontend, restart commands for Docker/Source/Kubernetes). - Do NOT include manual database migration commands (such as `alembic upgrade heads`), as database migrations run automatically on application startup. - Mimic the exact formatting, sections, and command blocks shown in the Style Reference below (excluding any manual database migration steps). 5. ## Notes: @@ -327,6 +314,7 @@ jobs: BASE_TAG: ${{ steps.version.outputs.base_tag }} TARGET_VERSION: ${{ steps.version.outputs.version }} TARGET_TAG: ${{ steps.version.outputs.tag }} + SOURCE_REF: ${{ github.ref_name }} run: | set -euo pipefail @@ -341,6 +329,7 @@ jobs: base_tag = os.environ["BASE_TAG"] target_version = os.environ["TARGET_VERSION"] target_tag = os.environ["TARGET_TAG"] + source_ref = os.environ["SOURCE_REF"] entries = [] for line in Path(".github/release-artifacts/commit-table.tsv").read_text(encoding="utf-8").splitlines(): @@ -382,7 +371,18 @@ jobs: "", "## Upgrade Guide", "", - "Merging the automated release PR publishes this tag and starts the production deployment. The server fetches the exact release tag, builds the Docker services from source, restarts them, and verifies `/api/health` automatically.", + "### Docker Deployment", + "```bash", + f"git pull origin {source_ref}", + "docker compose down && docker compose up -d --build", + "```", + "", + "### Source Deployment", + "```bash", + f"git pull origin {source_ref}", + "cd frontend && npm install && npm run build", + "cd ..", + "```", "", "## Notes", f"- Release generated from changes since `{base_tag}`.", @@ -500,38 +500,10 @@ jobs: prerelease: ${{ github.event.pull_request.draft }} generate_release_notes: false - resolve_deploy_tag: - name: Resolve deployment tag - needs: publish_release - if: >- - ${{ always() && - (needs.publish_release.result == 'success' || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy')) }} - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.tag.outputs.value }} - steps: - - name: Resolve and validate tag - id: tag - shell: bash - env: - PUBLISHED_TAG: ${{ needs.publish_release.outputs.tag }} - REQUESTED_TAG: ${{ inputs.deploy_tag }} - run: | - set -euo pipefail - - tag="${PUBLISHED_TAG:-$REQUESTED_TAG}" - if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then - echo "::error::A valid deploy_tag in vX.Y.Z form is required" - exit 1 - fi - - echo "value=$tag" >> "$GITHUB_OUTPUT" - deploy_release: name: Deploy release to production - needs: resolve_deploy_tag - if: ${{ always() && needs.resolve_deploy_tag.result == 'success' }} + needs: publish_release + if: ${{ needs.publish_release.result == 'success' }} runs-on: ubuntu-latest environment: production env: @@ -540,7 +512,7 @@ jobs: DEPLOY_PORT: ${{ vars.CLAWITH_DEPLOY_PORT || '10022' }} DEPLOY_PATH: ${{ vars.CLAWITH_DEPLOY_PATH || 'clawith_new' }} SOURCE_PATH: ${{ vars.CLAWITH_SOURCE_PATH || 'Clawith' }} - RELEASE_TAG: ${{ needs.resolve_deploy_tag.outputs.tag }} + RELEASE_TAG: ${{ needs.publish_release.outputs.tag }} SSH_PRIVATE_KEY: ${{ secrets.CLAWITH_DEPLOY_SSH_KEY }} SSH_KNOWN_HOSTS: ${{ secrets.CLAWITH_DEPLOY_KNOWN_HOSTS }} steps: diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md index 9a2324ba3..8e0aae4d3 100644 --- a/deploy/RELEASE_DEPLOYMENT.md +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -72,16 +72,3 @@ curl -fsS http://127.0.0.1:3008/api/health 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. - -An existing release can be redeployed without creating another release: - -```bash -gh workflow run release.yml \ - --ref main \ - -f operation=deploy \ - -f deploy_tag=v1.11.2 -``` - -The manual path uses the same tag validation, production Environment, SSH -credentials, source build, health check, and rollback behavior as an automatic -post-release deployment. From 2855e875fe850e7280cf46aba625941dbbb68f7e Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Fri, 24 Jul 2026 17:43:24 +0800 Subject: [PATCH 7/7] Speed up production dependency downloads --- .github/scripts/deploy-release.sh | 8 +++++++- .github/workflows/release.yml | 7 +++++-- deploy/RELEASE_DEPLOYMENT.md | 7 +++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/scripts/deploy-release.sh b/.github/scripts/deploy-release.sh index a8d36806c..d4205b2b9 100755 --- a/.github/scripts/deploy-release.sh +++ b/.github/scripts/deploy-release.sh @@ -5,6 +5,8 @@ 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 /*) ;; @@ -96,7 +98,11 @@ git checkout --detach "$release_tag" backend_release_image="clawith-backend:$release_tag" frontend_release_image="clawith-frontend:$release_tag" -"${docker_cmd[@]}" build -t "$backend_release_image" backend +"${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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f545ccd4..c06d09b89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -512,6 +512,8 @@ jobs: 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 }} @@ -559,8 +561,9 @@ jobs: run: | set -euo pipefail - printf -v remote_command 'bash -s -- %q %q %q' \ - "$RELEASE_TAG" "$DEPLOY_PATH" "$SOURCE_PATH" + 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" \ diff --git a/deploy/RELEASE_DEPLOYMENT.md b/deploy/RELEASE_DEPLOYMENT.md index 8e0aae4d3..5903c4936 100644 --- a/deploy/RELEASE_DEPLOYMENT.md +++ b/deploy/RELEASE_DEPLOYMENT.md @@ -28,6 +28,8 @@ protection rules can require approval before the deployment job starts. | `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 | ### Secrets @@ -69,6 +71,11 @@ 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.