Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 161 additions & 49 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,119 +1,231 @@
# V2 部署 pipeline:push main → 跑测 → SSH 到服务器 git pull + compose up
#
# GitHub repo Settings → Secrets 需要配:
# - SSH_PRIVATE_KEY:本机生成的 ~/.ssh/blog-deploy 私钥内容
# - SERVER_HOST :47.97.17.43
# - SERVER_USER :admin (Aliyun Cloud Linux 默认非 root 用户)
# - SERVER_DEPLOY_DIR: /opt/blog
#
# 第一次部署后,每次 push main 自动触发。
# 测试不过 deploy 不会跑。

name: Deploy to Production
# PR 与 main 共用同一组检查;只有 main 的 push 可以进入生产部署。
# 所有 Actions 都固定到完整提交 SHA,避免可移动标签引入供应链变化。

name: 持续集成与生产部署

on:
pull_request:
push:
branches: [main]
workflow_dispatch: # 允许手动触发

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: Run tests
name: API、Admin 与 Web
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:16-alpine
# 迁移会创建 vector 扩展,普通 postgres 镜像无法满足要求。
image: pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
env:
POSTGRES_USER: blog
POSTGRES_PASSWORD: blog
POSTGRES_DB: blog
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U blog"
--health-cmd "pg_isready -U blog -d blog"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://blog:blog@localhost:5432/blog?schema=public
JWT_SECRET: ci-test-secret
JWT_SECRET: ci-test-secret-not-for-production
JWT_EXPIRES_IN: 7d
AI_SERVICE_BASE_URL: http://127.0.0.1:8001
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- name: 检出代码
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: pnpm/action-setup@v4
- name: 安装 pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10.18.0

- uses: actions/setup-node@v4
- name: 安装 Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'pnpm'
cache: pnpm

- name: Install deps
- name: 安装依赖
run: pnpm install --frozen-lockfile

- name: Prisma generate
- name: 生成 Prisma Client
working-directory: apps/api
run: pnpm exec prisma generate

- name: Prisma migrate deploy
- name: 在空 pgvector 数据库执行迁移
working-directory: apps/api
run: pnpm exec prisma migrate deploy

- name: API unit tests
run: pnpm --filter api test
- name: 检查管理员初始化代码
run: pnpm --filter api exec eslint 'src/cli/**/*.ts'

- name: 运行 API 单元测试
run: pnpm --filter api exec jest --runInBand

- name: API e2e tests
- name: 运行 API 数据库 E2E
run: pnpm --filter api test:e2e

- name: Admin build
- name: 构建 API
run: pnpm --filter api build

- name: 构建 Admin
run: pnpm --filter admin build

- name: Web build
- name: 构建 Web
run: pnpm --filter web build

- name: 打包并校验 Web 产物
env:
ARTIFACT_BASENAME: web-output-${{ github.sha }}
run: |
set -euo pipefail
tar -czf "$RUNNER_TEMP/${ARTIFACT_BASENAME}.tar.gz" -C apps/web .output
cd "$RUNNER_TEMP"
sha256sum "${ARTIFACT_BASENAME}.tar.gz" > "${ARTIFACT_BASENAME}.tar.gz.sha256"
test -s "${ARTIFACT_BASENAME}.tar.gz"
test -s "${ARTIFACT_BASENAME}.tar.gz.sha256"

- name: 保存 commit 对应的 Web 产物
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: web-output-${{ github.sha }}
path: |
${{ runner.temp }}/web-output-${{ github.sha }}.tar.gz
${{ runner.temp }}/web-output-${{ github.sha }}.tar.gz.sha256
if-no-files-found: error
retention-days: 14
compression-level: 0

ai-test:
name: ai-service pytest
name: AI 服务
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- name: 检出代码
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: 安装 Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Install ai-service deps

- name: 安装 AI 服务依赖
working-directory: apps/ai-service
run: |
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
- name: Run pytest

- name: 运行 AI 服务测试
working-directory: apps/ai-service
env:
USE_MOCK_LLM: 'true'
XIAOMI_MIMO_API_KEY: 'ci-test-key'
XIAOMI_MIMO_API_KEY: ci-test-key
run: .venv/bin/pytest -v

deploy:
name: Deploy to server
name: 部署生产环境
needs: [test, ai-test]
if: github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 40
environment: production
concurrency:
group: production-deploy
cancel-in-progress: false
steps:
- name: SSH and deploy
uses: appleboy/ssh-action@v1.0.3
- name: 校验部署配置
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_DEPLOY_DIR: ${{ secrets.SERVER_DEPLOY_DIR }}
SERVER_SSH_FINGERPRINT: ${{ secrets.SERVER_SSH_FINGERPRINT }}
run: |
set -euo pipefail
test -n "$SERVER_HOST"
test -n "$SERVER_USER"
test -n "$SSH_PRIVATE_KEY"
test -n "$SERVER_DEPLOY_DIR"
test -n "$SERVER_SSH_FINGERPRINT"

- name: 下载本次检查生成的 Web 产物
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: web-output-${{ github.sha }}
path: deploy-artifact

- name: 传输 Web 产物
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
fingerprint: ${{ secrets.SERVER_SSH_FINGERPRINT }}
source: deploy-artifact/web-output-${{ github.sha }}.tar.gz,deploy-artifact/web-output-${{ github.sha }}.tar.gz.sha256
target: ${{ secrets.SERVER_DEPLOY_DIR }}/.deploy-artifacts/${{ github.sha }}
strip_components: 1
overwrite: true

- name: 检出同一 commit 并部署
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
fingerprint: ${{ secrets.SERVER_SSH_FINGERPRINT }}
command_timeout: 30m
script: |
set -euo pipefail
cd ${{ secrets.SERVER_DEPLOY_DIR }}
git fetch --all
git reset --hard origin/main
# 用项目根的 docker-compose.prod.yml + 服务器的 /opt/blog/.env

deploy_sha='${{ github.sha }}'
deploy_dir='${{ secrets.SERVER_DEPLOY_DIR }}'
artifact_dir="$deploy_dir/.deploy-artifacts/$deploy_sha"
artifact_file="$artifact_dir/web-output-$deploy_sha.tar.gz"

test -n "$deploy_dir"
test -f "$artifact_file"
test -f "$artifact_file.sha256"

cd "$artifact_dir"
sha256sum -c "$(basename "$artifact_file").sha256"

cd "$deploy_dir"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo '部署目录存在未提交的已跟踪改动,已停止部署。'
exit 1
fi

git fetch --no-tags origin main
git cat-file -e "$deploy_sha^{commit}"
git merge-base --is-ancestor "$deploy_sha" origin/main
git checkout --detach "$deploy_sha"
test "$(git rev-parse HEAD)" = "$deploy_sha"

staged_output="$deploy_dir/apps/web/.output.next-$deploy_sha"
current_output="$deploy_dir/apps/web/.output"
previous_output="$deploy_dir/apps/web/.output.previous"
rm -rf "$staged_output" "$previous_output"
mkdir -p "$staged_output"
tar -xzf "$artifact_file" --strip-components=1 -C "$staged_output"
test -f "$staged_output/server/index.mjs"

if [ -d "$current_output" ]; then
mv "$current_output" "$previous_output"
fi
mv "$staged_output" "$current_output"

docker compose -f docker-compose.prod.yml --env-file .env.production pull
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build
# 清理悬挂镜像
docker image prune -f
# 等所有服务 healthy
sleep 10
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml --env-file .env.production \
up -d --build --remove-orphans --wait --wait-timeout 180
docker compose -f docker-compose.prod.yml --env-file .env.production ps
printf '%s\n' "$deploy_sha" > "$artifact_dir/DEPLOYED_SHA"
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,32 @@ cd apps/mobile && flutter pub get && flutter run
└─────────────┘ └──────────────────────────┘
```

### 自动化检查与部署

Pull Request 会在隔离的 pgvector 数据库上执行迁移、API 测试以及 API、Admin、
Web 构建,并保存以 commit SHA 命名的 Nuxt `.output` 产物。Pull Request 不会读取
生产 Secret,也不会部署。

只有 `main` 的 push 且所有检查通过后才会部署。部署任务下载同一次运行生成的 Web
产物,校验 SHA-256,通过已验证主机指纹的 SSH 传输;服务器随后检出同一个 commit,
拒绝覆盖未提交的已跟踪改动,并等待 Compose 服务进入健康状态。

GitHub 的 `production` Environment 需要配置:

- `SERVER_HOST`:服务器地址
- `SERVER_USER`:非 root 部署用户
- `SSH_PRIVATE_KEY`:权限受限的部署私钥
- `SERVER_DEPLOY_DIR`:服务器仓库目录,例如 `/opt/blog`
- `SERVER_SSH_FINGERPRINT`:从云厂商控制台或其他可信通道取得的 SSH 主机公钥 SHA-256 指纹

首次启用前还需确认服务器的 Docker Compose 支持 `up --wait`,且部署用户可写
`SERVER_DEPLOY_DIR`。原来的手动 `workflow_dispatch` 入口已移除,避免绕过“main push
对应唯一构建产物”的约束。

回滚时优先在 GitHub 上 revert 问题提交并推送到 `main`,让同一流水线重新构建和部署。
服务器会保留上一次 `.output.previous` 和按 SHA 存放的传输产物,供流水线不可用时人工
恢复;数据库迁移不承诺可逆,涉及数据库变更时仍需先按现有备份流程验证恢复。

部署具体步骤、Caddyfile 模板、systemd 备份 timer 等见 [`docs/V2-REPORT.md`](docs/V2-REPORT.md) 和 [`scripts/`](scripts/)。

---
Expand Down
2 changes: 2 additions & 0 deletions apps/web/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ node_modules
*.log
.env
.env.*
.output.previous
.output.next-*
4 changes: 2 additions & 2 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
# `.output` 目录,绕过 oxide。
#
# 部署流程:
# 本地: cd apps/web && pnpm build # 生成 apps/web/.output
# 服务器: tar `.output` 同步过去, 然后 docker compose build web
# CI:构建 `.output`,按 commit SHA 打包并保存校验和
# 服务器:校验并解压同一 commit 的产物,再执行 docker compose build web

FROM node:22-alpine
WORKDIR /app
Expand Down
Loading