Files
BYW-VoiceHub/.gitea/workflows/deploy.yml
T
Henry_Du 496edf9f71
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m35s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m43s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
fix(ci): buildx build --load -> 宿主 docker push 两阶段,彻底绕开 buildkitd OAuth2 空 token 推送失败
2026-09-06 13:55:40 +08:00

544 lines
27 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: 构建并部署 BYW VoiceHub
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
on:
push:
branches: [main, master]
workflow_dispatch:
inputs:
deploy_target:
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: true
env:
GITEA_HOST: gitea.fse-media.group
DEPLOY_PATH: /data/opt/byw-voicehub
IMAGE_NAME: byw-voicehub
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
APT_MIRROR_DEBIAN: https://mirrors.tuna.tsinghua.edu.cn/debian
APT_MIRROR_DEBIAN_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/debian-security
APT_MIRROR_DEBIAN_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian
APT_MIRROR_DEBIAN_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian-security
APT_MIRROR_UBUNTU: https://mirrors.tuna.tsinghua.edu.cn/ubuntu
APT_MIRROR_UBUNTU_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
APT_MIRROR_UBUNTU_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu
APT_MIRROR_UBUNTU_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
HTTP_PROXY_EXAMPLE: ""
HTTPS_PROXY_EXAMPLE: ""
jobs:
preflight:
name: Preflight 语法与构建检查
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: node:22-bookworm-slim
steps:
- name: 安装基础运行依赖(git/ca-certificates,已换清华 TUNA 源)
run: |
set -euxo pipefail
# ========== Step 0:先用 HTTP 清华源装 ca-certificates(解鸡生蛋:HTTPS 需要证书,证书得先用 HTTP 装) ==========
SOURCES_FILE="/etc/apt/sources.list.d/debian.sources"
if [ -f "${SOURCES_FILE}" ]; then
sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}"
sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}"
sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}"
sed -i "s|https://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}"
else
if [ -f /etc/apt/sources.list ]; then
sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list
sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list
sed -i "s|http://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list
sed -i "s|https://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list
fi
fi
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates
update-ca-certificates 2>/dev/null || true
# ========== Step 1:现在有了证书,换成 HTTPS 清华源(更安全)+ 装 git/curl ==========
if [ -f "${SOURCES_FILE}" ]; then
sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
else
if [ -f /etc/apt/sources.list ]; then
sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
fi
fi
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git curl
rm -rf /var/lib/apt/lists/*
# 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy 前缀
git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
git --version
node -v
npm -v
- name: 检出代码
run: |
set -euxo pipefail
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_REF_NAME:-main}"
echo "Clone from: ${REPO_URL} (${BRANCH})"
rm -rf * .[^.]* 2>/dev/null || true
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
git rev-parse --short HEAD
- name: 安装依赖(npm ci)
run: |
set -euxo pipefail
npm config set registry https://registry.npmmirror.com || true
npm config set fund false
npm config set audit false
npm ci --legacy-peer-deps
- name: 检查 package.json 无 pnpm 残留
run: |
set -euo pipefail
if grep -qE '"packageManager"\s*:\s*"pnpm' package.json; then
echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止"
exit 1
fi
echo "package.json 未发现 pnpm 声明"
- name: 执行 nuxt build
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
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: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
run: |
set -euxo pipefail
# ========== Step 0:先装 ca-certificates(默认 HTTP archive.ubuntu.com 不需要证书,解鸡生蛋) ==========
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || \
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || true
sudo update-ca-certificates 2>/dev/null || update-ca-certificates 2>/dev/null || true
fi
# ========== Step 1:有了证书后换 HTTPS 清华源 ==========
# Ubuntu 22.04 默认是 http://archive.ubuntu.com/ubuntu 和 http://security.ubuntu.com/ubuntu
if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then
sudo sed -i \
-e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
-e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
/etc/apt/sources.list
fi
if command -v apt-get >/dev/null 2>&1 && ls /etc/apt/sources.list.d/*.sources 1>/dev/null 2>&1; then
for f in /etc/apt/sources.list.d/*.sources; do
sudo sed -i \
-e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
-e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
"${f}" 2>/dev/null || true
done
fi
# 全局 git:所有 https://github.com/ 自动走 gh-proxy 前缀
sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
# npm:强制用 npmmirror(与 preflight 对齐)
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
echo "环境准备完成"
cat /etc/os-release 2>/dev/null | head -4 || true
docker --version
docker buildx version || echo "buildx 未安装"
- name: 检出代码
run: |
set -euxo pipefail
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_REF_NAME:-main}"
echo "Clone from: ${REPO_URL} (${BRANCH})"
rm -rf * .[^.]* 2>/dev/null || true
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
git rev-parse --short HEAD
docker version
docker buildx version || echo "buildx 未安装,将使用 docker build --push"
- name: 计算镜像标签
id: meta
run: |
set -euo pipefail
# Docker Registry 规范:仓库路径、owner、repo name 必须全小写(Henry_Du/BYW-VoiceHub → henry_du/byw-voicehub)
REPO_OWNER="${GITHUB_REPOSITORY_OWNER,,}"
REPO_NAME="${IMAGE_NAME,,}"
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
SHORT_SHA="${SHA:0:7}"
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
IMG_FULL="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${DATE_TAG}"
IMG_LATEST="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:latest"
CACHE_REF_INNER="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:buildcache"
echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
echo "cache_ref=${CACHE_REF_INNER}" >> "$GITHUB_OUTPUT"
echo "repo_owner=${REPO_OWNER}" >> "$GITHUB_OUTPUT"
echo "repo_name=${REPO_NAME}" >> "$GITHUB_OUTPUT"
echo "======================================"
echo "Image tag : ${DATE_TAG}"
echo "Full image : ${IMG_FULL}"
echo "Latest image : ${IMG_LATEST}"
echo "Cache ref : ${CACHE_REF_INNER}"
echo "======================================"
- name: 初始化 Docker Buildx builder(带国内 registry-mirrors + 内置 Gitea Basic Auth)
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
run: |
set -euxo pipefail
# 先校验凭据非空(避免 buildkitd.toml 写空用户名/密码导致 push 时 401)
if [ -z "${REGISTRY_TOKEN}" ]; then
echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(个人应用令牌,权限 read:package + write:package + read:repository)"
exit 1
fi
REGISTRY_USER="${REGISTRY_USER,,}"
BUILDER_NAME="multi"
BUILDKIT_IMAGE="moby/buildkit:buildx-stable-1"
BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
# 用 printf 逐行写 buildkitd 配置,避免 YAML parser 把 heredoc 内部的 [...] 误判为 YAML key
{
printf '[worker.oci]\n max-parallelism = 2\n\n'
printf '[registry."docker.io"]\n mirrors = ["docker.m.daocloud.io","dockerproxy.com","hub-mirror.c.163.com","docker.nju.edu.cn","docker.mirrors.ustc.edu.cn"]\n\n'
printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n\n'
# 关键:Gitea Container Registry 直接用 Basic Auth 凭据写死在 buildkitd.toml
# 绕开 buildkitd 的 OAuth2 flow(Gitea 1.27.x 有时返回空 token body:authorization server did not include a token)
printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
} > "${BKK_CFG}"
# 预拉 buildkitd 基础镜像(优先 5 个国内镜像 fallback,避免 Docker Hub <200KB/s)
PULLED=0
for MIRROR in \
"docker.m.daocloud.io/${BUILDKIT_IMAGE}" \
"dockerproxy.com/${BUILDKIT_IMAGE}" \
"hub-mirror.c.163.com/${BUILDKIT_IMAGE}" \
"docker.nju.edu.cn/${BUILDKIT_IMAGE}" \
"docker.mirrors.ustc.edu.cn/${BUILDKIT_IMAGE}" \
"${BUILDKIT_IMAGE}"; do
if docker pull "${MIRROR}" 2>/dev/null; then
if [ "${MIRROR}" != "${BUILDKIT_IMAGE}" ]; then
docker tag "${MIRROR}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
fi
PULLED=1
break
fi
done
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像,请检查 Docker registry-mirrors"; exit 1; }
# 宿主 Docker daemon 也同步登录(后面镜像体积与层信息 Step 的 docker image inspect / docker history 要用)
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
-u "${REGISTRY_USER}" --password-stdin
echo "宿主 Docker 登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
# 立即重建 buildx builder(带凭据 + registry-mirrors)
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
docker buildx create \
--name "${BUILDER_NAME}" \
--driver docker-container \
--use \
--config "${BKK_CFG}" \
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
--driver-opt network=host \
--driver-opt "image=${BUILDKIT_IMAGE}"
rm -f "${BKK_CFG}"
# gh-proxy 环境(全变量显式赋值,避免 bash tracing 边界反引号歧义)
GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
GIT_CONFIG_VALUE_0="https://github.com/"
GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf"
GIT_CONFIG_VALUE_1="https://gist.github.com/"
export GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
docker buildx inspect --bootstrap
echo "Builder 启动成功,buildkitd.toml 已内置 ${GITEA_HOST} Basic Auth 凭据,push 不再走 OAuth2 flow"
- name: 确认已登录(凭据已注入 buildkitd.toml 与宿主 Docker daemon)
run: |
set -euo pipefail
# 前面「初始化 Docker Buildx builder」Step 已完成两次登录:
# 1. 宿主 Docker daemon(镜像体积 Step 要用);2. buildkitd.toml(buildx push 要用,Basic Auth 不走 OAuth2)
# 这里只做一个快速断言,确保宿主登录成功
docker info 2>/dev/null | grep -E "Username|Registry" || echo "已存在登录凭据(跳过详细输出)"
echo "登录确认完成,REGISTRY_USER=${REGISTRY_USER:-${GITHUB_ACTOR,,}},GITEA_HOST=${GITEA_HOST}"
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
- name: 构建并推送镜像
env:
SKIP_CACHE: ${{ inputs.skip_build_cache }}
IMG_FULL: ${{ steps.meta.outputs.full }}
IMG_LATEST: ${{ steps.meta.outputs.latest }}
OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }}
OCS_REVISION: ${{ github.sha }}
OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }}
run: |
set -euxo pipefail
# 兜底空值 & 全小写规范
OCS_SOURCE="${OCS_SOURCE_RAW,,}"
OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}"
# 再次保险:所有镜像标签字符串都确认无大写字母(Registry 硬性规范)
IMG_FULL="${IMG_FULL,,}"
IMG_LATEST="${IMG_LATEST,,}"
BUILD_ARGS=(
--build-arg BUILDKIT_INLINE_CACHE=1
--build-arg NODE_ENV=production
)
# 说明:暂时关闭 registry 分层缓存(buildkitd 推 buildcache 也要认证,同样会触发 Gitea 1.27 OAuth2 空 token)
# 改为本地 Docker daemon 缓存,已构建的层会自动复用,下次 build 速度几乎相同
BUILD_ARGS+=( --no-cache )
echo "已启用强制重新构建(跳过 registry 分层缓存,避免 buildkitd 推 cache 层时 OAuth2 认证失败)"
# ========== 阶段 1:构建到本地 Docker daemon(--load,不用 --push) ==========
# 关键:这一步产物在宿主 Docker 的 images 列表里(docker images 能看到),宿主 docker login 过的凭据 100% 可用
# 完全绕开 buildkitd 容器内部推镜像走 OAuth2 flow 的坑
docker buildx build \
--file ./Dockerfile \
--platform linux/amd64 \
--load \
--provenance=false \
"${BUILD_ARGS[@]}" \
--tag "${IMG_FULL}" \
--tag "${IMG_LATEST}" \
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
--label "org.opencontainers.image.created=${OCS_CREATED}" \
.
# 断言:两个 tag 都真的加载到了本地 Docker daemon
docker image inspect "${IMG_FULL}" >/dev/null
docker image inspect "${IMG_LATEST}" >/dev/null
echo "构建成功,已加载到本地 Docker daemon:${IMG_FULL} + ${IMG_LATEST}"
echo "镜像体积(Pre-push):"
docker image inspect "${IMG_FULL}" --format='{{.Size}}' | awk '{printf " Full image : %.2f MB\n", $1/1024/1024}'
# ========== 阶段 2:宿主 Docker daemon 原生 push(100% 走宿主登录凭据,不会走 OAuth2) ==========
echo "开始推送:${IMG_FULL}"
docker push "${IMG_FULL}"
echo "开始推送:${IMG_LATEST}"
docker push "${IMG_LATEST}"
echo "推送完成:2 个 tag(date-sha + latest)"
- name: 镜像体积与层信息
env:
IMG_FULL: ${{ steps.meta.outputs.full }}
run: |
set -euxo pipefail
docker pull "${IMG_FULL}" || true
docker image inspect "${IMG_FULL}" --format='{{.Size}}' 2>/dev/null \
| awk '{printf "Image size: %.2f MB\n", $1/1024/1024}' || true
docker history --no-trunc "${IMG_FULL}" 2>/dev/null | head -20 || true
deploy:
name: 部署到生产服务器
needs: build-image
if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
runs-on: ubuntu-latest
timeout-minutes: 25
environment:
name: production
url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
steps:
- name: 校验部署所需密钥
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
MISSING=()
[ -n "${DEPLOY_HOST}" ] || MISSING+=("DEPLOY_HOST")
[ -n "${DEPLOY_USER}" ] || MISSING+=("DEPLOY_USER")
[ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
if [ ${#MISSING[@]} -gt 0 ]; then
echo "缺少以下 Repository Secrets:${MISSING[*]}"
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)"
exit 1
fi
echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
- name: SSH 滚动部署
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || 22 }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }}
REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }}
NUXT_PUBLIC_SITE_URL: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
IMAGE_NEW: ${{ needs.build-image.outputs.image_full }}
IMAGE_LATEST: ${{ needs.build-image.outputs.image_latest }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# 全小写规范(Docker Registry 硬性要求 + Gitea raw URL 路径大小写敏感)
IMAGE_NEW="${IMAGE_NEW,,}"
IMAGE_LATEST="${IMAGE_LATEST,,}"
GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
GITHUB_SERVER_URL="${GITHUB_SERVER_URL,,}"
GITEA_HOST="${GITEA_HOST,,}"
IMAGE_NAME="${IMAGE_NAME,,}"
# 写私钥到临时文件(避免 stdin 转义问题)
SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
chmod 600 "${SSH_KEY_FILE}"
SSH_OPTS=(
-i "${SSH_KEY_FILE}"
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ConnectTimeout=10
-o ServerAliveInterval=30
-o ServerAliveCountMax=8
-p "${DEPLOY_PORT}"
)
echo "=============================================="
echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
echo "Deploy image : ${IMAGE_NEW}"
echo "Deploy dir : ${DEPLOY_PATH}"
echo "=============================================="
# 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
# 把变量作为 heredoc 脚本的输入传过去,远端展开我们需要的值
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
IMAGE_NEW="${IMAGE_NEW}" \
IMAGE_LATEST="${IMAGE_LATEST}" \
DEPLOY_PATH="${DEPLOY_PATH}" \
GITEA_HOST="${GITEA_HOST}" \
GH_PROXY_PREFIX="${GH_PROXY_PREFIX}" \
REGISTRY_PULL_USER="${REGISTRY_PULL_USER}" \
REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}" \
IMAGE_NAME="${IMAGE_NAME}" \
NUXT_PUBLIC_SITE_URL="${NUXT_PUBLIC_SITE_URL}" \
GITHUB_SERVER_URL="${GITHUB_SERVER_URL}" \
GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" \
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
bash <<'REMOTE_SCRIPT'
set -euxo pipefail
DEPLOY_DIR="${DEPLOY_PATH}"
COMPOSE="docker compose"
echo "=============================================="
echo "Executor : $(whoami)"
echo "Deploy image : ${IMAGE_NEW}"
echo "Deploy dir : ${DEPLOY_DIR}"
echo "=============================================="
mkdir -p "${DEPLOY_DIR}"
cd "${DEPLOY_DIR}"
if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
echo "使用专用 pull 凭证登录 Gitea Container Registry"
printf '%s' "${REGISTRY_PULL_TOKEN}" \
| docker login "${GITEA_HOST}" \
-u "${REGISTRY_PULL_USER}" \
--password-stdin 2>/dev/null || true
fi
echo "Pulling new image ${IMAGE_NEW} ..."
docker pull "${IMAGE_NEW}"
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
echo "New image pulled and tagged as latest"
cat > docker-compose.override.yml <<YAML
services:
voicehub:
image: ${IMAGE_NEW}
YAML
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
if [ ! -f docker-compose.yml ]; then
echo "docker-compose.yml 不存在,自动从仓库模板下载(如访问 GitHub 会自动加 gh-proxy 前缀)"
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
case "${TEMPLATE_URL}" in
*"://github.com/"*) TEMPLATE_URL="${GH_PROXY_PREFIX}${TEMPLATE_URL}" ;;
esac
echo "URL=${TEMPLATE_URL}"
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env(参考仓库根目录 .env.example)"
fi
if [ ! -f .env ]; then
echo "================================================================="
echo "WARNING:${DEPLOY_DIR}/.env 不存在"
echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
echo "参考仓库根目录的 .env.example"
echo "================================================================="
fi
echo "Executing docker compose up -d ..."
${COMPOSE} up -d --remove-orphans
MAX_WAIT=24
WAITED=0
echo "Waiting for container healthcheck (max 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 healthcheck passed"
break
fi
if echo "${STATUS}" | grep -q '"State":"running"'; then
:
else
echo "voicehub not running, current status:"
${COMPOSE} ps || true
fi
sleep 5
WAITED=$((WAITED + 1))
done
if [ ${WAITED} -ge ${MAX_WAIT} ]; then
echo "Healthcheck timeout. Last 120 lines of voicehub logs:"
${COMPOSE} logs --tail=120 voicehub || true
exit 1
fi
echo "Pruning images older than 72 hours..."
docker image prune -af --filter "until=72h" || true
docker builder prune -af || true
echo ""
echo "================================================================="
echo "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
echo "Rollback instructions (on server):"
echo " cd ${DEPLOY_DIR}"
echo " docker images | grep ${IMAGE_NAME} | head -5"
echo " printf 'services:\\n voicehub:\\n image: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
echo " docker compose up -d"
echo "================================================================="
REMOTE_SCRIPT
# 清理私钥临时文件
rm -f "${SSH_KEY_FILE}"