ci: 新增 Gitea Actions 部署流水线 + Runner/Deploy 一键脚本
This commit is contained in:
+247
-90
@@ -1,164 +1,321 @@
|
||||
name: 构建并部署 VoiceHub
|
||||
run-name: ${{ gitea.actor }} 触发部署 #${{{{ gitea.run_number }}}}
|
||||
name: 构建 & 部署 BYW VoiceHub
|
||||
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
# 允许在 Gitea 页面手动点「运行工作流」
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_target:
|
||||
description: 是否在构建后自动部署到服务器
|
||||
description: 构建完成后是否自动部署到服务器
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
skip_build_cache:
|
||||
description: 跳过 BuildKit 分层缓存,强制重新构建(排错用)
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
|
||||
# 同一时刻只跑一个部署,避免部署竞态
|
||||
# 同一时刻只允许一个部署在跑,避免冲突;如果新提交进来,取消前一个未完成的
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# ====== 按需修改(改成你的 Gitea 域名,不要 http:// 前缀) ======
|
||||
GITEA_REGISTRY: gitea.example.com
|
||||
# ====== 一般不改 ======
|
||||
# ============================================================
|
||||
# 👇👇👇 这里改成你自己的 Gitea 域名(不要 https:// 前缀,不要端口默认 443/80)
|
||||
# 例:git.baoyiwai.com 或 gitea.example.com:3000
|
||||
GITEA_HOST: gitea.example.com
|
||||
# 👆👆👆
|
||||
# ============================================================
|
||||
IMAGE_NAME: byw-voicehub
|
||||
DEPLOY_PATH: /opt/byw-voicehub
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
# ============================================================
|
||||
# Job 1: 构建 & 推送 Docker 镜像到 Gitea 自带容器仓库
|
||||
# Job 1: 校验构建环境 & 打包产物(npm ci + build,不依赖 Docker)
|
||||
# ============================================================
|
||||
build-image:
|
||||
name: 构建 Docker 镜像
|
||||
preflight:
|
||||
name: Preflight 语法 & 构建检查
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.tag }}
|
||||
image_full: ${{ steps.meta.outputs.full }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: 📥 拉取代码
|
||||
- name: 📥 Checkout 代码
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🔧 设置 Node.js
|
||||
- name: 🔧 安装 Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmmirror.com # 国内加速,不需要可删
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: 🧪 环境校验
|
||||
- name: 🧪 安装依赖(npm ci,严格按锁文件)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
node -v
|
||||
npm -v
|
||||
npm ci --no-audit --no-fund
|
||||
# 国内加速镜像(Runner 在国外可以删掉这两行)
|
||||
npm config set registry https://registry.npmmirror.com || true
|
||||
npm config set fund false
|
||||
npm config set audit false
|
||||
npm ci
|
||||
|
||||
- name: 🔐 登录 Gitea 容器仓库
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN || gitea.token }}
|
||||
|
||||
- name: 🏷️ 生成镜像标签
|
||||
id: meta
|
||||
- name: 🔎 检查 package.json 没有 pnpm 残留
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT_SHA="${GITEA_SHA:0:7}"
|
||||
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
|
||||
FULL="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:${DATE_TAG}"
|
||||
LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest"
|
||||
echo "tag=${DATE_TAG}" >> "$GITEA_OUTPUT"
|
||||
echo "full=${FULL}" >> "$GITEA_OUTPUT"
|
||||
echo "latest=${LATEST}" >> "$GITEA_OUTPUT"
|
||||
echo "✅ 镜像 tag: ${DATE_TAG}"
|
||||
echo "✅ 镜像完整: ${FULL}"
|
||||
if grep -qE '"packageManager"\s*:\s*"pnpm' package.json; then
|
||||
echo "❌ package.json 里有 pnpm 包管理器声明,和用户要求冲突"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ package.json 未发现 pnpm 声明"
|
||||
|
||||
- name: 🐳 构建并推送镜像(BuildKit 多阶段 + 缓存)
|
||||
- name: 🏗️ 本地 nuxt build(独立于 Docker 先验证代码是可构建的)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
NODE_OPTIONS="--max-old-space-size=2048" npm run build
|
||||
echo "✅ .output/ 生成成功:"
|
||||
ls -la .output/ 2>/dev/null || true
|
||||
du -sh .output 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# Job 2: 构建 Docker 镜像 → 推送到 Gitea 自带容器仓库
|
||||
# ============================================================
|
||||
build-image:
|
||||
name: 构建并推送镜像
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.tag }}
|
||||
image_full: ${{ steps.meta.outputs.full }}
|
||||
image_latest: ${{ steps.meta.outputs.latest }}
|
||||
steps:
|
||||
- name: 📥 Checkout 代码
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🏷️ 计算镜像 tag(日期 + 短 SHA)
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
|
||||
IMG_FULL="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:${DATE_TAG}"
|
||||
IMG_LATEST="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:latest"
|
||||
echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "======================================"
|
||||
echo "🪪 镜像版本 tag : ${DATE_TAG}"
|
||||
echo "🪪 镜像完整名 : ${IMG_FULL}"
|
||||
echo "🪪 镜像 latest : ${IMG_LATEST}"
|
||||
echo "======================================"
|
||||
|
||||
- name: 🔧 启用 QEMU(跨架构小概率用得到,保险)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: 🔧 启用 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: 🔐 登录 Gitea 容器仓库(Packages)
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITEA_HOST }}
|
||||
# Secrets 里 REGISTRY_USER 没填就默认用触发构建的用户
|
||||
username: ${{ secrets.REGISTRY_USER || github.actor }}
|
||||
# Secrets 里 REGISTRY_TOKEN 优先,没填就用 Gitea Actions 自动注入的仓库级 token
|
||||
password: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
||||
|
||||
- name: 🐳 构建 & 推送(BuildKit 分层缓存,skip_build_cache=true 时禁用)
|
||||
id: build_push
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
CACHE_REF: ${{ env.GITEA_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
tags: |
|
||||
${{ steps.meta.outputs.full }}
|
||||
${{ steps.meta.outputs.latest }}
|
||||
cache-from: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
NODE_ENV=production
|
||||
cache-from: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0}', env.CACHE_REF) }}
|
||||
cache-to: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0},mode=max', env.CACHE_REF) }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
|
||||
- name: 🧮 镜像体积 & 层信息(日志里看一下,避免镜像越来越大)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker pull "${{ steps.meta.outputs.full }}" || true
|
||||
docker image inspect "${{ steps.meta.outputs.full }}" --format='{{.Size}}' 2>/dev/null \
|
||||
| awk '{printf "✅ 镜像体积:%.2f MB\n", $1/1024/1024}' || true
|
||||
docker history --no-trunc "${{ steps.meta.outputs.full }}" 2>/dev/null | head -20 || true
|
||||
|
||||
# ============================================================
|
||||
# Job 2: SSH 到服务器拉取新镜像并重启
|
||||
# Job 3: SSH 到服务器 → 拉新镜像 → 滚动重启
|
||||
# ============================================================
|
||||
deploy:
|
||||
name: 部署到服务器
|
||||
name: 部署到生产服务器
|
||||
needs: build-image
|
||||
if: ${{ !cancelled() && !failure() && (inputs.deploy_target == true || github.event_name == 'push') }}
|
||||
# push 事件默认自动部署;手动触发时看 checkbox
|
||||
if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
name: production
|
||||
url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
|
||||
steps:
|
||||
- name: 🌐 SSH 部署到生产服务器
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
- name: ⚠️ 检查部署所需 Secrets 是否都配置了
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MISSING=()
|
||||
[ -n "${{ secrets.DEPLOY_HOST }}" ] || MISSING+=("DEPLOY_HOST")
|
||||
[ -n "${{ secrets.DEPLOY_USER }}" ] || MISSING+=("DEPLOY_USER")
|
||||
[ -n "${{ secrets.DEPLOY_SSH_KEY }}" ] || MISSING+=("DEPLOY_SSH_KEY")
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
echo "❌ 缺少以下 Repository Secrets:${MISSING[*]}"
|
||||
echo "👉 去 Gitea 仓库 → Settings → Secrets and variables → Actions 里添加"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 部署 Secrets 配置完整"
|
||||
|
||||
- name: 🚀 一键滚动部署(SSH)
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
port: ${{ secrets.DEPLOY_PORT || 22 }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT || 22 }}
|
||||
script_stop: true
|
||||
# 部署可能慢,给足 30 分钟
|
||||
command_timeout: 30m
|
||||
# 出错立刻停,不继续往下跑
|
||||
script_stop: true
|
||||
script: |
|
||||
set -euxo pipefail
|
||||
|
||||
# 1) 首次部署时自动创建目录 & 空的 docker-compose.yml/.env
|
||||
mkdir -p "${DEPLOY_PATH}"
|
||||
cd "${DEPLOY_PATH}"
|
||||
# ---------- 0. 基本参数 ----------
|
||||
IMAGE_NEW="${{ needs.build-image.outputs.image_full }}"
|
||||
IMAGE_LATEST="${{ needs.build-image.outputs.image_latest }}"
|
||||
DEPLOY_DIR="${DEPLOY_PATH}"
|
||||
COMPOSE="docker compose"
|
||||
|
||||
# 2) 登录 Gitea 镜像仓库(用一个仅 pull 权限的 token 更安全)
|
||||
echo "${{ secrets.REGISTRY_PULL_TOKEN || gitea.token }}" \
|
||||
| docker login "${GITEA_REGISTRY}" -u "${{ secrets.REGISTRY_PULL_USER || gitea.repository_owner }}" --password-stdin 2>/dev/null || true
|
||||
echo "=============================================="
|
||||
echo "🚢 本次部署镜像: ${IMAGE_NEW}"
|
||||
echo "🗂️ 部署目录 : ${DEPLOY_DIR}"
|
||||
echo "👤 执行用户 : $(whoami)"
|
||||
echo "=============================================="
|
||||
|
||||
# 3) 拉最新镜像
|
||||
IMAGE_LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest"
|
||||
docker pull "$IMAGE_LATEST"
|
||||
# ---------- 1. 确保部署目录存在 ----------
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
cd "${DEPLOY_DIR}"
|
||||
|
||||
# 4) 写一个最小的 docker-compose.override.yml,指向刚拉的镜像
|
||||
# (避免改动服务器上已配置好的 docker-compose.yml 和 .env)
|
||||
cat > docker-compose.override.yml <<'YAML'
|
||||
services:
|
||||
voicehub:
|
||||
image: __REPLACE_WITH_IMAGE__
|
||||
YAML
|
||||
sed -i "s|__REPLACE_WITH_IMAGE__|${IMAGE_LATEST}|g" docker-compose.override.yml
|
||||
|
||||
# 5) 启动(如果没有 docker-compose.yml 就自动从你的仓库默认模板里下载一份)
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
echo "ℹ️ 首次部署:写入默认 docker-compose.yml 模板(请稍后在服务器上编辑 .env)"
|
||||
curl -fsSL "${{ gitea.server_url }}/${{ gitea.repository }}/raw/branch/main/docker-compose.yml" \
|
||||
-o docker-compose.yml || echo "下载失败,跳过(你需要手动上传 docker-compose.yml)"
|
||||
# ---------- 2. 登录 Gitea 容器仓库(用 pull token,最小权限) ----------
|
||||
if [ -n "${{ secrets.REGISTRY_PULL_TOKEN }}" ] && [ -n "${{ secrets.REGISTRY_PULL_USER }}" ]; then
|
||||
echo "🔐 使用专用 pull 凭证登录 Gitea Container Registry"
|
||||
echo "${{ secrets.REGISTRY_PULL_TOKEN }}" \
|
||||
| docker login "${GITEA_HOST}" \
|
||||
-u "${{ secrets.REGISTRY_PULL_USER }}" \
|
||||
--password-stdin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 6) 健康拉取 + 滚动重启(先拉好再 up,downtime 最小)
|
||||
docker compose pull --quiet voicehub 2>/dev/null || true
|
||||
docker compose up -d --remove-orphans
|
||||
# ---------- 3. 先拉镜像(提前下好,后面 up 不会卡) ----------
|
||||
echo "⬇️ 开始拉取新镜像 ${IMAGE_NEW} ..."
|
||||
docker pull "${IMAGE_NEW}"
|
||||
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
|
||||
echo "✅ 新镜像拉取完成,并已打 latest tag"
|
||||
|
||||
# 7) 等健康检查通过,最多等 120s
|
||||
for i in $(seq 1 24); do
|
||||
if docker compose ps --format json 2>/dev/null | grep -q '"Health":"healthy"'; then
|
||||
echo "✅ 容器健康"
|
||||
# ---------- 4. 生成 / 更新 docker-compose.override.yml ----------
|
||||
# 指向新的精确 tag(不是 latest,出问题能快速回滚)
|
||||
cat > docker-compose.override.yml <<YAML
|
||||
services:
|
||||
voicehub:
|
||||
image: ${IMAGE_NEW}
|
||||
YAML
|
||||
echo "📝 docker-compose.override.yml 已写入,指定镜像=${IMAGE_NEW}"
|
||||
|
||||
# ---------- 5. 没有 docker-compose.yml? 自动从仓库下一份模板 ----------
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
echo "ℹ️ 未发现 docker-compose.yml,自动从仓库模板下载"
|
||||
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY:-${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
|
||||
echo " ↓ URL=${TEMPLATE_URL}"
|
||||
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|
||||
|| (
|
||||
echo "⚠️ 自动下载模板失败,跳过(你需要手动上传 docker-compose.yml 和 .env)"
|
||||
# 不 exit,因为用户可能已经有一套自定义部署
|
||||
)
|
||||
fi
|
||||
|
||||
# ---------- 6. 没有 .env? 给一份占位(方便首次部署发现问题) ----------
|
||||
if [ ! -f .env ]; then
|
||||
echo "================================================================="
|
||||
echo "⚠️ WARNING:${DEPLOY_DIR}/.env 不存在!!!"
|
||||
echo "👉 第一次部署请先在服务器上创建 .env(至少填 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
|
||||
echo "👉 参考仓库根的 .env.example 或部署文档"
|
||||
echo "================================================================="
|
||||
# 不退出,因为 compose 会用 environment 默认值尝试启动(只是大概率连不上 DB)
|
||||
fi
|
||||
|
||||
# ---------- 7. 滚动启动(如果只有 voicehub 单服务,不会动 postgres/redis) ----------
|
||||
echo "🔄 docker compose up -d(只重建变更过的 voicehub)..."
|
||||
${COMPOSE} up -d --remove-orphans
|
||||
|
||||
# ---------- 8. 等待应用变 healthy,最多 2 分钟 ----------
|
||||
MAX_WAIT=24 # 24 × 5s = 2min
|
||||
WAITED=0
|
||||
echo "⏳ 等待容器 healthcheck 通过(最多 120s)..."
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
STATUS="$(${COMPOSE} ps --format json 2>/dev/null || true)"
|
||||
if echo "${STATUS}" | grep -q '"Health":"healthy"'; then
|
||||
echo "✅ voicehub 容器健康检查通过"
|
||||
break
|
||||
fi
|
||||
# 同时也检查是否已经 running 但健康检查还没跑完(防止 healthcheck 写太严误判)
|
||||
if echo "${STATUS}" | grep -q '"State":"running"'; then
|
||||
:
|
||||
else
|
||||
# 连 running 都不是 → 直接打印日志末尾 60 行帮忙排错
|
||||
echo "⚠️ voicehub 还没 running,当前状态:"
|
||||
${COMPOSE} ps || true
|
||||
fi
|
||||
sleep 5
|
||||
WAITED=$((WAITED + 1))
|
||||
done
|
||||
if [ ${WAITED} -ge ${MAX_WAIT} ]; then
|
||||
echo "⚠️ 等待超时,下面是最近 120 行 voicehub 日志:"
|
||||
${COMPOSE} logs --tail=120 voicehub || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 8) 清理旧镜像(只留最近 2 个 tag,省磁盘)
|
||||
# ---------- 9. 清理旧镜像(保留最近 2 个 tag + latest) ----------
|
||||
echo "🧹 清理 3 天前的旧镜像 / 悬空层,防止磁盘爆"
|
||||
docker image prune -af --filter "until=72h" || true
|
||||
docker builder prune -af || true
|
||||
|
||||
echo "🎉 部署完成!镜像=${{ needs.build-image.outputs.image_full }}"
|
||||
# ---------- 10. 回滚指引 ----------
|
||||
echo ""
|
||||
echo "================================================================="
|
||||
echo "🎉 部署成功!访问: ${{ secrets.NUXT_PUBLIC_SITE_URL || '请查看你的站点 URL' }}"
|
||||
echo "🔁 如需紧急回滚到上一个版本,在服务器执行:"
|
||||
echo " cd ${DEPLOY_DIR}"
|
||||
echo " # 1) 先看有哪些 tag:"
|
||||
echo " docker images | grep ${IMAGE_NAME} | head -5"
|
||||
echo " # 2) 选一个老 tag 填到 override:"
|
||||
echo " echo 'services:\n voicehub:\n image: ${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:<旧TAG>' > docker-compose.override.yml"
|
||||
echo " docker compose up -d"
|
||||
echo "================================================================="
|
||||
|
||||
Reference in New Issue
Block a user