Files
BYW-VoiceHub/.gitea/workflows/deploy.yml
T
Henry_Du 87276f5add
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m9s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m19s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 5s
fix(ci): bump preflight node heap from 2G to 4G - resolves TASK-67 nitro esbuild OOM exit 137
2026-09-12 00:20:08 +08:00

672 lines
36 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: ""
# ========== Docker 官方镜像国内加速(Runner 拉 node:alpine / buildkit 等最常见卡点) ==========
# 注意:如果你知道哪个镜像源在内网最快,可以在仓库 Variables 里覆盖 DOCKER_REGISTRY_MIRROR_1/2/3
DOCKER_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
# Dockerfile 内 FROM 前缀替换(ARG DOCKER_MIRROR=…),避免 buildx build 内部 FROM node:22-alpine 再被 Hub 限速
DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
NODE_BUILDER_TAG: "22-alpine"
NODE_RUNTIME_TAG: "22-alpine"
jobs:
preflight:
name: Preflight 语法与构建检查
runs-on: ubuntu-latest
timeout-minutes: 15
# 关键修复 1:去掉 container.image: node:22-bookworm-slim
# Runner 在 Set up job 阶段会先 docker pull,国内连 Docker Hub 被限速会一直卡转圈。
# 关键修复 2:去掉 uses: actions/setup-node@v4
# setup-node 会直连 GitHub Releases (github.com/actions/node-versions/releases/download/...)
# 下载 Node 二进制,国内被限速 → 一直卡在 Acquiring node-v*-linux-x64.tar.gz。
# 与 build-image job「禁止第三方 uses、全部用代理/国内源」风格完全对齐:
# - Runner 自带 node ≥22 直接用
# - 否则优先 npmmirror 国内镜像下载 Node 22 二进制
# - 最后兜底用 GH_PROXY_PREFIX 代理 GitHub node-versions Releases 直链
env:
NODE_MAJOR: "22"
NODE_VERSION_FALLBACK: "22.13.0"
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
steps:
- name: 安装基础运行依赖(git/curl/ca-certificates/tar/xz-utils,Runner 自带)
run: |
set -euxo pipefail
command -v git || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git ca-certificates)
command -v curl || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends curl ca-certificates)
command -v tar || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends tar)
command -v xz || command -v unxz || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends xz-utils) || true
# 预先打印 Runner 自带版本,方便排障
command -v node && echo "系统 node: $(node -v)" || echo "系统 node: 未安装"
command -v npm && echo "系统 npm: $(npm -v)" || echo "系统 npm: 未安装"
- name: 确保 Node.js 22 可用(npmmirror 优先 / gh-proxy 兜底 / 绝不直连 GitHub)
run: |
set -euxo pipefail
# 先判断:当前系统 node 主版本 ≥ NODE_MAJOR 直接跳过
if command -v node >/dev/null 2>&1; then
CUR_NODE_MAJOR="$(node -v | tr -d 'v' | cut -d. -f1 || echo 0)"
if [ "${CUR_NODE_MAJOR:-0}" -ge "${NODE_MAJOR}" ]; then
echo "Runner 已自带 Node $(node -v)(≥ v${NODE_MAJOR}.x),直接复用,不下载"
node -v; npm -v
exit 0
fi
echo "Runner 自带 Node v${CUR_NODE_MAJOR} 不足 v${NODE_MAJOR}.x,手动升级"
fi
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) PLAT="linux-x64" ;;
aarch64|arm64) PLAT="linux-arm64" ;;
armv7l) PLAT="linux-armv7l" ;;
*) echo "不支持的架构:$ARCH" >&2; exit 1 ;;
esac
echo "目标架构:$PLAT"
INSTALL_DIR="${RUNNER_TEMP:-/tmp}/node-${NODE_MAJOR}"
mkdir -p "$INSTALL_DIR"
# 找 Node 22 最新 patch 版本号:不用 GitHub API,走 npmmirror 的 index.json 解析
NODE_VER="${NODE_VERSION_FALLBACK}"
if command -v python3 >/dev/null 2>&1; then
PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdiA9IGYie21ham9yfS57bWlub3J9LntwYXRjaH0iCmlmIGJlc3RfdjoKICAgIHByaW50KGJlc3Rfdik="
REMOTE_LATEST=""
REMOTE_LATEST="$(curl -fsSL --max-time 20 "${NODE_MIRROR_PRIMARY}/index.json" 2>/dev/null \
| printf '%s' "$PY_NODE_B64" | base64 -d | python3 - "${NODE_MAJOR}" || true)"
if [ -n "$REMOTE_LATEST" ]; then
NODE_VER="$REMOTE_LATEST"
echo "npmmirror 查得 Node 22 latest: v${NODE_VER}"
else
echo "npmmirror index 查询失败,使用兜底: v${NODE_VER}"
fi
else
echo "python3 不可用,跳过 npmmirror 最新版本探测,使用兜底: v${NODE_VER}"
fi
TAR_XZ="node-v${NODE_VER}-${PLAT}.tar.xz"
mkdir -p /tmp/node-dl-cache
# 下载 URL 1:npmmirror 国内二进制镜像(首选,国内最快)
URL_1="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
# 下载 URL 2:nodejs.org 官方直链 + GH_PROXY 前缀兜底(绝不直连 GitHub.com)
URL_2="${GH_PROXY_PREFIX}https://nodejs.org/dist/v${NODE_VER}/${TAR_XZ}"
DL_OK=0
for URL in "$URL_1" "$URL_2"; do
echo "尝试下载:$URL"
if curl -fsSL --max-time 300 --retry 3 --retry-delay 2 -o "/tmp/node-dl-cache/${TAR_XZ}" "$URL"; then
echo "下载 OK:$URL"
DL_OK=1
break
fi
echo "下载失败,尝试下一个源:$URL"
done
if [ "$DL_OK" != "1" ]; then
echo "所有 Node 下载源均失败(npmmirror + gh-proxy),中止" >&2
exit 1
fi
# 解压并加到 PATH,GITHUB_ENV 注入所有后续 step 自动继承
tar -xJf "/tmp/node-dl-cache/${TAR_XZ}" -C "$INSTALL_DIR" --strip-components=1
if [ -f "$GITHUB_ENV" ]; then
echo "PATH=$INSTALL_DIR/bin:$PATH" >> "$GITHUB_ENV"
fi
export PATH="$INSTALL_DIR/bin:$PATH"
hash -r 2>/dev/null || true
node -v
npm -v
# 把 npm 全局前缀也设在这个目录下,避免权限问题
npm config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
- name: 校验 Node + npm + git 可用
run: |
set -euxo pipefail
# 全局 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
# npm registry 走 npmmirror,避免后续 npm ci 也卡
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || 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=4096" 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
# ========== 关键修复:宿主机 Docker daemon 先注入国内 registry mirrors ==========
# 原因:Runner 任何 docker pull / buildx create(moby/buildkit 拉基础镜像)都要先经过 daemon
# 如果直接连 docker.io,国内会被限速到 <200KB/s,导致 Setup / BuildKit 拉镜像一直卡。
# 注意:此处用 PY_CODE 字符串 + printf 管道传 Python 代码给 stdin,
# 避免 <<'PY' heredoc 结构与 YAML run:| 块标量缩进冲突导致的 could not find expected ':' 报错。
DOCKER_DAEMON_CONF="/etc/docker/daemon.json"
if command -v docker >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
sudo mkdir -p /etc/docker 2>/dev/null || true
OLD_MIRRORS_JSON="{}"
if [ -f "${DOCKER_DAEMON_CONF}" ]; then
OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
fi
PY_DAEMON_B64="aW1wb3J0IGpzb24sIHN5cwp0cnk6CiAgICBkYXRhID0ganNvbi5sb2FkcyhzeXMuYXJndlsxXSBvciAie30iKQpleGNlcHQgRXhjZXB0aW9uOgogICAgZGF0YSA9IHt9Cm1pcnJvcnNfZW52cyA9IFttIGZvciBtIGluIHN5cy5hcmd2WzI6NV0gaWYgbV0KZXhpc3RpbmcgPSBkYXRhLmdldCgicmVnaXN0cnktbWlycm9ycyIpIG9yIFtdCmlmIGlzaW5zdGFuY2UoZXhpc3RpbmcsIGxpc3QpOgogICAgY29tYmluZWQgPSBsaXN0KGRpY3QuZnJvbWtleXMoWyptaXJyb3JzX2VudnMsICpleGlzdGluZ10pKQplbHNlOgogICAgY29tYmluZWQgPSBtaXJyb3JzX2VudnMKaWYgY29tYmluZWQ6CiAgICBkYXRhWyJyZWdpc3RyeS1taXJyb3JzIl0gPSBjb21iaW5lZApkYXRhLnNldGRlZmF1bHQoImxvZy1kcml2ZXIiLCAianNvbi1maWxlIikKcHJpbnQoanNvbi5kdW1wcyhkYXRhLCBlbnN1cmVfYXNjaWk9RmFsc2UsIGluZGVudD0yKSk="
NEW_DAEMON_JSON="$(printf '%s' "$PY_DAEMON_B64" | base64 -d | python3 - "$OLD_MIRRORS_JSON" \
"${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}")"
echo "${NEW_DAEMON_JSON}" | sudo tee "${DOCKER_DAEMON_CONF}" >/dev/null
echo "[Docker] daemon.json 已注入 registry-mirrors:"
sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || true
# 非侵入式重启 docker;runner 没 systemd 时用 service 兜底;失败也跳过(buildkitd.toml 内部仍有 mirrors)
sudo systemctl restart docker 2>/dev/null \
|| sudo service docker restart 2>/dev/null \
|| echo "[Docker] systemctl/service 重启失败,跳过(后续 buildx builder 内部仍有 registry mirrors)"
sleep 3 2>/dev/null || true
fi
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
--build-arg DOCKER_MIRROR="${DOCKER_MIRROR_PREFIX}"
--build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}"
--build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}"
)
# 说明:暂时关闭 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"
# 用 printf 拼 override.yml,彻底避免 bash heredoc 的三重坑:
# 1) 结束分隔符必须行首(否则 bash 不认,把后面整段代码都写进文件)
# 2) 内层 <<'XXX' 单引号包裹导致变量不展开(image: ${IMAGE_NEW} 会是字面量)
# 3) 外层 .yml parser 把 heredoc 里的 services: 当 YAML mapping key 解析
printf 'services:\n voicehub:\n image: %s\n' "${IMAGE_NEW}" > docker-compose.override.yml
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
echo "--- override.yml content ---"
cat docker-compose.override.yml
echo "--- override.yml end ---"
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}"