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=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 # ========== 关键修复:宿主机 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: \\n' > docker-compose.override.yml" echo " docker compose up -d" echo "=================================================================" REMOTE_SCRIPT # 清理私钥临时文件 rm -f "${SSH_KEY_FILE}"