From e699ff13b3ac844558ae0395112abfe63f057c7c Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 7 Jul 2026 15:02:02 +0800 Subject: [PATCH] ci(deploy): deploy images over ssh from gitea actions --- .gitea/workflows/docker-image.yml | 99 +++++++++++++++++++++++ docs/auto-build-restart.md | 42 +++------- docs/docker-image-ci.md | 29 ++----- docs/gitea-actions-ssh-deploy.md | 110 ++++++++++++++++++++++++++ scripts/deploy/_deploy_lib.sh | 8 +- scripts/deploy/deploy_web.sh | 6 +- scripts/deploy/deploy_web_test.sh | 5 +- scripts/server/deploy-docker-image.sh | 53 +++++++++++++ 8 files changed, 290 insertions(+), 62 deletions(-) create mode 100644 docs/gitea-actions-ssh-deploy.md create mode 100755 scripts/server/deploy-docker-image.sh diff --git a/.gitea/workflows/docker-image.yml b/.gitea/workflows/docker-image.yml index 771239c9..3e827835 100644 --- a/.gitea/workflows/docker-image.yml +++ b/.gitea/workflows/docker-image.yml @@ -131,6 +131,105 @@ jobs: docker push "$IMAGE_VERSION_TAG" docker push "$IMAGE_LATEST_TAG" + - name: Deploy image over SSH + env: + TEST_SSH_HOST: ${{ secrets.TEST_SSH_HOST }} + TEST_SSH_USER: ${{ secrets.TEST_SSH_USER }} + TEST_SSH_PORT: ${{ secrets.TEST_SSH_PORT }} + TEST_SSH_PRIVATE_KEY: ${{ secrets.TEST_SSH_PRIVATE_KEY }} + TEST_SSH_KNOWN_HOSTS: ${{ secrets.TEST_SSH_KNOWN_HOSTS }} + TEST_DEPLOY_DIR: ${{ secrets.TEST_DEPLOY_DIR }} + PROD_SSH_HOST: ${{ secrets.PROD_SSH_HOST }} + PROD_SSH_USER: ${{ secrets.PROD_SSH_USER }} + PROD_SSH_PORT: ${{ secrets.PROD_SSH_PORT }} + PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }} + PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} + PROD_DEPLOY_DIR: ${{ secrets.PROD_DEPLOY_DIR }} + REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -eu + + case "$DEPLOY_ENV" in + test) + DEPLOY_HOST="$TEST_SSH_HOST" + DEPLOY_USER="$TEST_SSH_USER" + DEPLOY_PORT="${TEST_SSH_PORT:-22}" + DEPLOY_KEY="$TEST_SSH_PRIVATE_KEY" + DEPLOY_KNOWN_HOSTS="$TEST_SSH_KNOWN_HOSTS" + DEPLOY_DIR="${TEST_DEPLOY_DIR:-/opt/cozsweet-web-test}" + HOST_PORT="9135" + CONTAINER_NAME="cozsweet-web-test" + PROJECT_NAME="cozsweet-web-test" + ;; + prod) + DEPLOY_HOST="$PROD_SSH_HOST" + DEPLOY_USER="$PROD_SSH_USER" + DEPLOY_PORT="${PROD_SSH_PORT:-22}" + DEPLOY_KEY="$PROD_SSH_PRIVATE_KEY" + DEPLOY_KNOWN_HOSTS="$PROD_SSH_KNOWN_HOSTS" + DEPLOY_DIR="${PROD_DEPLOY_DIR:-/opt/cozsweet-web-prod}" + HOST_PORT="9185" + CONTAINER_NAME="cozsweet-web-prod" + PROJECT_NAME="cozsweet-web-prod" + ;; + *) + echo "Unsupported deploy env: $DEPLOY_ENV" >&2 + exit 1 + ;; + esac + + if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_KEY" ]; then + echo "Missing SSH deployment secrets for $DEPLOY_ENV" >&2 + exit 1 + fi + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + KEY_FILE="$HOME/.ssh/cozsweet_deploy_key" + printf '%s\n' "$DEPLOY_KEY" > "$KEY_FILE" + chmod 600 "$KEY_FILE" + + if [ -n "$DEPLOY_KNOWN_HOSTS" ]; then + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + else + ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts + fi + + SSH_TARGET="$DEPLOY_USER@$DEPLOY_HOST" + SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes" + SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes" + + ssh $SSH_OPTS "$SSH_TARGET" "mkdir -p '$DEPLOY_DIR'" + scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml" + scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh" + scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$DEPLOY_DIR/$NEXT_ENV_FILE" + + printf '%s' "$REGISTRY_PASSWORD" \ + | ssh $SSH_OPTS "$SSH_TARGET" \ + "docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin" + + ssh $SSH_OPTS "$SSH_TARGET" \ + "cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME'" + + - name: Purge Cloudflare cache + env: + CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + run: | + set -eu + + if [ -z "$CF_ZONE_ID" ] || [ -z "$CF_API_TOKEN" ]; then + echo "Cloudflare secrets are not configured, skip purge." + exit 0 + fi + + curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"purge_everything":true}' + - name: Cleanup build env file if: always() run: | diff --git a/docs/auto-build-restart.md b/docs/auto-build-restart.md index b58ffb01..8a2fdb78 100644 --- a/docs/auto-build-restart.md +++ b/docs/auto-build-restart.md @@ -1,12 +1,14 @@ -# 自动构建与自动重启流程 +# 自动构建与自动重启流程(旧 Git Hook 方案) -本文档说明当前项目通过 Git remote + `post-receive` hook 实现自动部署、自动重启的流程。当前服务器端运行方式已经容器化,核心文件为 [.githooks/post-receive](../.githooks/post-receive)、[Dockerfile](../Dockerfile) 和 [docker-compose.yml](../docker-compose.yml)。 +本文档记录旧的 Git remote + `post-receive` hook 部署流程,仅作为回滚参考。当前推荐方案已迁移为 Gitea Actions 构建镜像并通过 SSH 部署,见 [Gitea Actions SSH 部署流程](./gitea-actions-ssh-deploy.md)。 + +旧方案核心文件为 [.githooks/post-receive](../.githooks/post-receive)、[Dockerfile](../Dockerfile) 和 [docker-compose.yml](../docker-compose.yml)。 服务器默认仍会本地构建 Docker 镜像;如果服务器工作树中存在 `.deploy.env` 且配置 `COZSWEET_DEPLOY_IMAGE_SOURCE=pull`,则会改为从镜像仓库拉取由 Gitea Actions 构建好的镜像。 -## 部署入口 +## 旧部署入口 -当前涉及两个服务器 remote: +旧方案涉及两个服务器 remote: | 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 | | --- | --- | --- | --- | --- | --- | @@ -20,48 +22,30 @@ test root@43.106.13.130:/root/cozsweet-repos/test production root@43.106.13.130:/root/cozsweet-repos/main ``` -常用发布入口: - -```bash -# 发布测试环境 -./scripts/release/pre_release_web.sh - -# 发布生产环境 -./scripts/release/release_web.sh -``` - -也可以只执行推送脚本: - -```bash -# 推送 test remote 的 test 分支 -./scripts/deploy/deploy_web_test.sh - -# 推送 production remote 的 main 分支 -./scripts/deploy/deploy_web.sh -``` +当前日常发布入口已经改为推送 Gitea 分支并触发 Actions,详见 [Gitea Actions SSH 部署流程](./gitea-actions-ssh-deploy.md)。`scripts/deploy/*` 已改为推送 `gitea` remote,不再推送服务器 remote。旧方案如果需要手动触发,需要直接操作对应服务器仓库。 ## 本地发布流程 -测试环境发布流程: +旧测试环境发布流程(脚本迁移前): 1. 进入 test worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test` 2. 确认当前分支。 3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。 4. 复制测试环境图标到 `public/`。 5. 复制 `env-example/.env.local.example` 到 `.env.local`。 -6. 执行 `scripts/deploy/deploy_web_test.sh`。 -7. `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。 +6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。 +7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。 8. 推送成功后尝试清除 Cloudflare CDN 缓存。 -生产环境发布流程: +旧生产环境发布流程(脚本迁移前): 1. 进入 main worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-main` 2. 确认当前分支。 3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `main` 分支。 4. 复制生产环境图标到 `public/`。 5. 复制 `env-example/.env.production.example` 到 `.env.production`。 -6. 执行 `scripts/deploy/deploy_web.sh`。 -7. `deploy_web.sh` 强制推送 `production` remote 的 `main` 分支。 +6. 执行旧版 `scripts/deploy/deploy_web.sh`。 +7. 旧版 `deploy_web.sh` 强制推送 `production` remote 的 `main` 分支。 8. 推送成功后尝试清除 Cloudflare CDN 缓存。 ## 服务器端 post-receive 流程 diff --git a/docs/docker-image-ci.md b/docs/docker-image-ci.md index 7745189d..b32cc466 100644 --- a/docs/docker-image-ci.md +++ b/docs/docker-image-ci.md @@ -1,6 +1,6 @@ # Docker 镜像持续构建与发布 -本文档说明 Gitea Actions 中 Docker 镜像构建与发布 workflow 的使用方式。对应脚本为 [.gitea/workflows/docker-image.yml](../.gitea/workflows/docker-image.yml)。 +本文档说明 Gitea Actions 中 Docker 镜像构建、发布与 SSH 部署 workflow 的使用方式。对应脚本为 [.gitea/workflows/docker-image.yml](../.gitea/workflows/docker-image.yml)。 ## 职责划分 @@ -9,7 +9,7 @@ | Workflow | 触发时机 | 职责 | | --- | --- | --- | | `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、Lint、单元测试、Next.js 构建 | -| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像并推送到镜像仓库 | +| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 | 这样可以避免所有开发分支都发布镜像,也能让代码检查失败时不污染镜像仓库。 @@ -90,36 +90,19 @@ docker image ls | grep cozsweet-web ## 部署服务器拉取镜像 -部署服务器需要先登录镜像仓库: +当前推荐由 Gitea Actions 通过 SSH 登录部署服务器并执行部署脚本,不再依赖服务器 Git remote 的 `post-receive` hook。完整说明见 [Gitea Actions SSH 部署流程](./gitea-actions-ssh-deploy.md)。 + +如果需要在服务器上手动验证镜像拉取,可先登录镜像仓库: ```bash docker login REGISTRY_HOST ``` -然后在部署服务器工作树中创建 `.deploy.env`: - -```bash -cp env-example/.deploy.env.example .deploy.env -``` - -配置示例: - -```env -COZSWEET_DEPLOY_IMAGE_SOURCE=pull -COZSWEET_REGISTRY_IMAGE=gitea.banlv-ai.com/admin/cozsweet-web -``` - -`post-receive` 会根据当前分支和 commit 自动拉取: +Actions 部署时会根据当前分支和 commit 自动拉取: ```bash gitea.banlv-ai.com/admin/cozsweet-web:test- gitea.banlv-ai.com/admin/cozsweet-web:prod- ``` -如果需要改用 latest tag,可以在服务器 `.deploy.env` 中显式设置: - -```env -COZSWEET_REMOTE_IMAGE_TAG=prod-latest -``` - 推荐优先使用默认的 `-` 精确 tag,这样发布和回滚都更可控。 diff --git a/docs/gitea-actions-ssh-deploy.md b/docs/gitea-actions-ssh-deploy.md new file mode 100644 index 00000000..2809b67e --- /dev/null +++ b/docs/gitea-actions-ssh-deploy.md @@ -0,0 +1,110 @@ +# Gitea Actions SSH 部署流程 + +当前推荐部署链路: + +```text +push test/main +→ Gitea Actions 构建并推送 Docker 镜像 +→ Actions 通过 SSH 登录目标服务器 +→ 目标服务器 docker pull 指定镜像 tag +→ docker compose up -d --no-build +``` + +服务器不再依赖 Git remote 的 `post-receive` hook 拉取或构建镜像。 + +## 分支与环境 + +| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 | +| --- | --- | --- | --- | --- | --- | +| `test` | 测试环境 | `test-` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` | +| `main` | 生产环境 | `prod-` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` | + +## Gitea Secrets + +已有镜像构建 Secrets: + +| Secret | 说明 | +| --- | --- | +| `REGISTRY_HOST` | Docker registry host,例如 `gitea.banlv-ai.com` | +| `REGISTRY_IMAGE` | 镜像仓库,例如 `gitea.banlv-ai.com/admin/cozsweet-web` | +| `REGISTRY_USERNAME` | registry 用户名 | +| `REGISTRY_PASSWORD` | registry 密码或 token | +| `TEST_ENV_FILE` | 测试环境 `.env.local` 内容 | +| `PRODUCTION_ENV_FILE` | 生产环境 `.env.production` 内容 | + +新增 SSH 部署 Secrets: + +| Secret | 说明 | +| --- | --- | +| `TEST_SSH_HOST` / `PROD_SSH_HOST` | 测试 / 生产服务器地址 | +| `TEST_SSH_USER` / `PROD_SSH_USER` | SSH 用户,例如 `root` | +| `TEST_SSH_PRIVATE_KEY` / `PROD_SSH_PRIVATE_KEY` | 私钥内容 | +| `TEST_SSH_PORT` / `PROD_SSH_PORT` | 可选,默认 `22` | +| `TEST_SSH_KNOWN_HOSTS` / `PROD_SSH_KNOWN_HOSTS` | 可选,推荐配置 `ssh-keyscan` 输出 | +| `TEST_DEPLOY_DIR` / `PROD_DEPLOY_DIR` | 可选,覆盖默认服务器目录 | +| `CF_ZONE_ID` / `CF_API_TOKEN` | 可选,部署成功后清理 Cloudflare 缓存 | + +## 服务器要求 + +目标服务器需要: + +1. 安装 Docker,并支持 `docker compose` 或 `docker-compose`。 +2. SSH 用户可执行 Docker 命令。 +3. 能访问 Gitea registry。 +4. 部署目录允许 SSH 用户写入。 + +workflow 会自动同步以下文件到服务器部署目录: + +```text +docker-compose.yml +deploy-docker-image.sh +.env.local 或 .env.production +``` + +## 本地发布入口 + +本地发布脚本现在只负责推送 Gitea 分支,触发 Actions: + +```bash +# 测试环境 +./scripts/release/pre_release_web.sh + +# 生产环境 +./scripts/release/release_web.sh +``` + +也可以直接推送: + +```bash +git push gitea test +git push --force gitea main +``` + +## 验证 + +测试环境: + +```bash +ssh @ +cd /opt/cozsweet-web-test +docker compose ps +curl -I http://127.0.0.1:9135/ +``` + +生产环境: + +```bash +ssh @ +cd /opt/cozsweet-web-prod +docker compose ps +curl -I http://127.0.0.1:9185/ +``` + +## 旧 hook 处理 + +服务器上的 Git remote `post-receive` hook 属于旧部署链路。迁移完成并验证 Actions SSH 部署成功后,可以在服务器上禁用旧 hook,避免误触发: + +```bash +mv /root/cozsweet-repos/test/.git/hooks/post-receive /root/cozsweet-repos/test/.git/hooks/post-receive.disabled +mv /root/cozsweet-repos/main/.git/hooks/post-receive /root/cozsweet-repos/main/.git/hooks/post-receive.disabled +``` diff --git a/scripts/deploy/_deploy_lib.sh b/scripts/deploy/_deploy_lib.sh index 30579942..17b0d595 100755 --- a/scripts/deploy/_deploy_lib.sh +++ b/scripts/deploy/_deploy_lib.sh @@ -1,9 +1,9 @@ #!/bin/bash -# 推送指定分支到指定远端 -# 期望服务器端 post-receive hook 已就位;本脚本只做 git push,不负责构建/启动 -# 调用方:deploy_web.sh(production)→ push_to_remote "production" "main" -# deploy_web_test.sh(test)→ push_to_remote "test" "test" +# 推送指定分支到指定远端。 +# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。 +# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main" +# deploy_web_test.sh(test)→ push_to_remote "gitea" "test" push_to_remote() { local remote="$1" local branch="$2" diff --git a/scripts/deploy/deploy_web.sh b/scripts/deploy/deploy_web.sh index 39ce1fc3..99891426 100755 --- a/scripts/deploy/deploy_web.sh +++ b/scripts/deploy/deploy_web.sh @@ -1,6 +1,6 @@ #!/bin/bash # ========================================== -# 生产环境 Web 部署脚本(Next.js + git push 模型) +# 生产环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型) # 原始 Dart: scripts/deploy/deploy_web.sh # ========================================== @@ -11,8 +11,8 @@ source "$SCRIPT_DIR/_deploy_lib.sh" # 主函数 main() { - push_to_remote "production" "main" - purge_cdn_cache + push_to_remote "gitea" "main" + # NEW_VERSION=$(bump_version) } main diff --git a/scripts/deploy/deploy_web_test.sh b/scripts/deploy/deploy_web_test.sh index 3a28a510..fc22ae5f 100755 --- a/scripts/deploy/deploy_web_test.sh +++ b/scripts/deploy/deploy_web_test.sh @@ -1,6 +1,6 @@ #!/bin/bash # ========================================== -# 测试环境 Web 部署脚本(Next.js + git push 模型) +# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型) # 原始 Dart: scripts/deploy/deploy_web_test.sh # ========================================== @@ -11,8 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh" # 主函数 main() { - push_to_remote "test" "test" - purge_cdn_cache + push_to_remote "gitea" "test" } main diff --git a/scripts/server/deploy-docker-image.sh b/scripts/server/deploy-docker-image.sh new file mode 100755 index 00000000..5dfbd6aa --- /dev/null +++ b/scripts/server/deploy-docker-image.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE="${1:-}" +ENV_FILE="${2:-}" +HOST_PORT="${3:-}" +CONTAINER_NAME="${4:-}" +PROJECT_NAME="${5:-}" + +if [ -z "$IMAGE" ] || [ -z "$ENV_FILE" ] || [ -z "$HOST_PORT" ] || [ -z "$CONTAINER_NAME" ] || [ -z "$PROJECT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +if [ ! -f "docker-compose.yml" ]; then + echo "Missing docker-compose.yml in $(pwd)" >&2 + exit 66 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing env file: $ENV_FILE" >&2 + exit 66 +fi + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + echo "docker compose command not found" >&2 + exit 127 +fi + +export COZSWEET_IMAGE="$IMAGE" +export COZSWEET_ENV_FILE="$ENV_FILE" +export COZSWEET_HOST_PORT="$HOST_PORT" +export COZSWEET_CONTAINER_NAME="$CONTAINER_NAME" +export COMPOSE_PROJECT_NAME="$PROJECT_NAME" + +echo "=== deploy image: $COZSWEET_IMAGE ===" +echo "=== env file: $COZSWEET_ENV_FILE ===" +echo "=== host port: $COZSWEET_HOST_PORT ===" +echo "=== container: $COZSWEET_CONTAINER_NAME ===" +echo "=== compose project: $COMPOSE_PROJECT_NAME ===" + +"${COMPOSE[@]}" pull web +"${COMPOSE[@]}" up -d --remove-orphans --no-build web +"${COMPOSE[@]}" ps + +docker image prune -f + +echo "=== deploy done ==="