564 lines
28 KiB
YAML
564 lines
28 KiB
YAML
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}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
|
||
cd "${DEPLOY_DIR}"
|
||
|
||
# ========== 目录可写性预检查(权限排查友好化) ==========
|
||
TEST_FILE="${DEPLOY_DIR}/.deploy_writable_test_$$"
|
||
if ! (touch "${TEST_FILE}" && rm -f "${TEST_FILE}") 2>/dev/null; then
|
||
echo "================================================================="
|
||
echo "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
|
||
echo "当前用户:$(whoami)"
|
||
echo "目录归属与权限:"
|
||
ls -ld "${DEPLOY_DIR}" || true
|
||
echo ""
|
||
echo "请在服务器上以 root 身份执行下面 3 行命令修复(复制粘贴):"
|
||
echo " chown -R deploy:docker '${DEPLOY_DIR}'"
|
||
echo " chmod 775 '${DEPLOY_DIR}'"
|
||
echo " setfacl -R -m u:deploy:rwx -m g:docker:rwx -m d:u:deploy:rwx -m d:g:docker:rwx '${DEPLOY_DIR}' 2>/dev/null || true"
|
||
echo "修复后重新触发此 workflow 即可。"
|
||
echo "================================================================="
|
||
exit 3
|
||
fi
|
||
|
||
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"
|
||
|
||
# heredoc 分隔符用 COMPOSE_OVERRIDE,不能用 YAML(外层是 .yml 文件,YAML parser 会冲突)
|
||
# 注意:heredoc 内容和结束 COMPOSE_OVERRIDE 行必须保持同样的缩进(跟 run block 对齐),否则 YAML parser 会把 services: 当 YAML key 解析
|
||
cat > docker-compose.override.yml <<'COMPOSE_OVERRIDE'
|
||
services:
|
||
voicehub:
|
||
image: ${IMAGE_NEW}
|
||
COMPOSE_OVERRIDE
|
||
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 + 5 个 cpus 数字兜底:APP_CPUS/APP_CPUS_LIMIT/APP_CPUS_RESERVATION/PG_CPUS/REDIS_CPUS)"
|
||
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}"
|