Files
BYW-VoiceHub/.gitea/workflows/deploy.yml
T
Henry_Du 17f2aa7fcd fix(deploy): 把 WECHAT_MINI_SUBSCRIBE_TEMPLATES 加进 ENV_PAYLOAD(远端 unbound variable)
上一次只补了 step 的 env: 映射,但那只是【本地】runner 侧;
远端脚本的变量是靠 ENV_PAYLOAD 逐个 export 过去的。于是远端
set -euxo pipefail 读到未定义的  直接退出:
  bash: line 71: WECHAT_MINI_SUBSCRIBE_TEMPLATES: unbound variable
部署到生产服务器这一步就这样挂了。

现在两处都齐:step env: 映射 + ENV_PAYLOAD 的 export(并用  兜底,
Secret 没配也不会 unbound)。

验证(Git Bash 实测):
- printf 占位符 21 个 vs 参数 21 个一致,payload 含新变量
- source 生成的 env 脚本(set -u)→ 变量有定义、JSON 原样保留
- Secret 为空 → 定义为空字符串,同样安全
- 另外扫了整个远端脚本:引用的 29 个大写变量都有来源(payload 或自赋值)
2026-09-27 15:37:58 +08:00

1092 lines
59 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_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
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: 12
env:
NODE_MAJOR: "22"
NODE_VERSION_FALLBACK: "22.13.0"
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
NODE_OPTIONS: "--max-old-space-size=8192 --no-warnings --expose-gc"
NITRO_PRESET: "node-server"
DISABLE_ROLLDOWN: "1"
NITRO_MINIFY: "false"
VOICEHUB_NPM_CACHE_DIR: "${HOME}/.cache/voicehub-npm"
steps:
- name: 安装基础依赖 + 确保 Node 22 可用 + 校验 node/git/npm 可用
run: |
set -euxo pipefail
# 先统一探测:只有当下列命令/包任意一个缺失时,才统一执行 1 次 apt-get update + install
NEED_APT_UPDATE=0
need_cmd() {
local cmd="$1"
if ! command -v "${cmd}" >/dev/null 2>&1; then
NEED_APT_UPDATE=1
return 1
fi
return 0
}
need_pkg() {
local pkg="$1"
if command -v dpkg >/dev/null 2>&1; then
if dpkg -s "${pkg}" >/dev/null 2>&1; then
return 0
fi
NEED_APT_UPDATE=1
return 1
fi
return 0
}
NEED_GIT=0; need_cmd git || NEED_GIT=1
NEED_CURL=0; need_cmd curl || NEED_CURL=1
NEED_CA=0; need_pkg ca-certificates || NEED_CA=1
NEED_TAR=0; need_cmd tar || NEED_TAR=1
NEED_XZ=0; (need_cmd xz || need_cmd unxz) || NEED_XZ=1
APT_MISSING=()
[ "${NEED_GIT}" = "1" ] && APT_MISSING+=("git")
[ "${NEED_CURL}" = "1" ] && APT_MISSING+=("curl")
[ "${NEED_CA}" = "1" ] && APT_MISSING+=("ca-certificates")
[ "${NEED_TAR}" = "1" ] && APT_MISSING+=("tar")
[ "${NEED_XZ}" = "1" ] && APT_MISSING+=("xz-utils")
if [ "${#APT_MISSING[@]}" -gt 0 ]; then
echo "[apt][preflight] 检测到缺包: ${APT_MISSING[*]},执行 1 次 apt-get update + install"
if command -v sudo >/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 "${APT_MISSING[@]}" 2>/dev/null || \
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
else
apt-get update -qq 2>/dev/null || true
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
fi
else
echo "[apt][preflight] 所有基础命令/包已存在,跳过 apt-get update(节省 30-90s)"
fi
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
else
echo "Runner 自带 Node v${CUR_NODE_MAJOR} 不足 v${NODE_MAJOR}.x,手动升级"
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_VER="${NODE_VERSION_FALLBACK}"
if command -v python3 >/dev/null 2>&1; then
PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdjAgPSBmInttYWpvcn0ue21pbm9yfS57cGF0Y2h9IgppZiBiZXN0X3Y6CiAgICBwcmludChiZXN0X3Yp"
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="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
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
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 config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
fi
else
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_VER="${NODE_VERSION_FALLBACK}"
if command -v python3 >/dev/null 2>&1; then
PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdjAgPSBmInttYWpvcn0ue21pbm9yfS57cGF0Y2h9IgppZiBiZXN0X3Y6CiAgICBwcmludChiZXN0X3Yp"
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="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
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
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 config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
fi
git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
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: 注入 OAuth secrets 到 GITHUB_ENV
env:
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
run: |
set -euo pipefail
for pair in "OAUTH_REDIRECT_URI:${OAUTH_REDIRECT_URI:-}" "OAUTH_STATE_SECRET:${OAUTH_STATE_SECRET:-}" "AGGREGATE_OAUTH_APP_ID:${AGGREGATE_OAUTH_APP_ID:-}" "AGGREGATE_OAUTH_APP_KEY:${AGGREGATE_OAUTH_APP_KEY:-}" "AGGREGATE_OAUTH_LOGIN_TYPE:${AGGREGATE_OAUTH_LOGIN_TYPE:-}" "AGGREGATE_OAUTH_ENDPOINT:${AGGREGATE_OAUTH_ENDPOINT:-}"; do K="${pair%%:*}"; V="${pair#*:}"; if [ -n "${V}" ]; then printf '%s="%s"\n' "$K" "$V" >> "$GITHUB_ENV"; echo "[OAuth-secrets] $K=已注入 (len=${#V})"; else echo "[OAuth-secrets] $K=未配置(Repository Secrets 中缺失或为空)"; fi; done; echo "[OAuth-secrets] 写入完成;后续所有 step 都会自动把上述变量作为进程级 process.env.* 继承(包括 nuxt build)"
- name: 安装依赖(npm ci,含 Runner 本地磁盘缓存 + 双源重试 + npm 版本对齐)
run: |
set -euo pipefail
# 关键:本地生成 package-lock.json 使用的 npm 版本为 11.6.2(Node 24),
# 而 Preflight 默认 Node 22 自带 npm 10.x,lockfileVersion/校验不匹配会导致
# npm ci 报错并输出 help(其中包含 --install-links 字样,与你看到的日志一致)。
# 所以在任何 ci 操作之前,先把 npm 升级到与本地生成 lockfile 一致的大版本。
echo "=== Pre-upgrade npm to 11.x ==="
npm -v || true
if command -v sudo >/dev/null 2>&1; then
sudo npm install -g "npm@^11.6.2" 2>/dev/null || \
sudo npm install -g "npm@latest-11" 2>/dev/null || true
else
npm install -g "npm@^11.6.2" 2>/dev/null || \
npm install -g "npm@latest-11" 2>/dev/null || true
fi
hash -r 2>/dev/null || true
npm -v
LOCK_HASH="$(sha256sum package-lock.json | cut -d' ' -f1)"
CACHE_FILE="${VOICEHUB_NPM_CACHE_DIR}/pf-${LOCK_HASH}.tgz"
mkdir -p "${VOICEHUB_NPM_CACHE_DIR}"
if [ -f "${CACHE_FILE}" ]; then
echo "[npm cache] HIT: ${CACHE_FILE},直接恢复 node_modules,跳过 npm ci"
tar -xzf "${CACHE_FILE}"
echo "[npm cache] 恢复完成"
else
echo "[npm cache] MISS: ${CACHE_FILE},执行 npm ci 并写入缓存"
NPM_REGISTRY_PRIMARY="${NPM_REGISTRY_PRIMARY:-https://registry.npmmirror.com}"
NPM_REGISTRY_FALLBACK="${NPM_REGISTRY_FALLBACK:-https://mirrors.cloud.tencent.com/npm/}"
MAX_RETRY=3
DELAY_SEC=15
CI_OK=0
for ATTEMPT in $(seq 1 $MAX_RETRY); do
if [ $(( ATTEMPT % 2 )) -eq 1 ]; then
CUR_REGISTRY="${NPM_REGISTRY_PRIMARY}"
else
CUR_REGISTRY="${NPM_REGISTRY_FALLBACK}"
fi
echo "--- [npm ci attempt $ATTEMPT/$MAX_RETRY] registry=${CUR_REGISTRY} npm=$(npm -v) node=$(node -v) ---"
npm config set registry "${CUR_REGISTRY}" || true
npm config set fund false
npm config set audit false
npm config set prefer-offline true 2>/dev/null || true
npm config set cache-min 3600 2>/dev/null || true
npm config set fetch-retries 5 2>/dev/null || true
npm config set fetch-retry-maxtimeout 300000 2>/dev/null || true
npm config set fetch-timeout 300000 2>/dev/null || true
npm config set fetch-retry-mintimeout 20000 2>/dev/null || true
set +e
npm ci --legacy-peer-deps
CI_EXIT=$?
set -e
if [ ${CI_EXIT} -eq 0 ]; then
CI_OK=1
echo "[npm ci] attempt ${ATTEMPT} SUCCESS"
break
fi
echo "[npm ci] attempt ${ATTEMPT} FAILED(exit=${CI_EXIT})"
echo " last 40 lines of stderr / help(若出现 --install-links 字样,通常是 npm<11 vs lockfile 版本不匹配):"
echo " node=$(node -v) npm=$(npm -v) lockfileVersion=$(node -e "try{const j=require('./package-lock.json');console.log(j.lockfileVersion||'n/a')}catch(e){console.log('n/a')}" 2>/dev/null || echo n/a)"
if [ $ATTEMPT -lt $MAX_RETRY ]; then
echo " ${DELAY_SEC}s 后重试下一轮(若还有)..."
sleep $DELAY_SEC
fi
done
if [ "${CI_OK}" != "1" ]; then
# 兜底:npm ci 只要 lockfile 有任何不同步(比如本地生成 vs CI Node 版本导致的可选依赖差异、
# 典型如 @parcel/watcher 在 CI Node 24 上作为可选依赖被拉起但 lock 中缺记录)就会 EUSAGE。
# 此时退化为 npm install --legacy-peer-deps(生成与本机一致的 node_modules + 同步 lock),
# 并用新 lock 重新算 hash 写缓存,避免下次相同 tree 再走到这里。
echo "WARN: npm ci 全部轮次仍失败,尝试降级为 npm install --legacy-peer-deps 兜底(会同步 package-lock.json)"
set +e
npm install --legacy-peer-deps --no-audit --no-fund
FALLBACK_EXIT=$?
set -e
if [ ${FALLBACK_EXIT} -eq 0 ]; then
echo "WARN: fallback npm install SUCCESS(后续建议本地执行 npm install 并提交新的 package-lock.json 以消除这一步)"
FALLBACK_LOCK_HASH="$(sha256sum package-lock.json | cut -d' ' -f1)"
FALLBACK_CACHE_FILE="${VOICEHUB_NPM_CACHE_DIR}/pf-${FALLBACK_LOCK_HASH}.tgz"
if [ "${FALLBACK_CACHE_FILE}" != "${CACHE_FILE}" ] && [ ! -f "${FALLBACK_CACHE_FILE}" ]; then
tar -czf "${FALLBACK_CACHE_FILE}" node_modules
echo "[npm cache][fallback] 已写入缓存: ${FALLBACK_CACHE_FILE}"
fi
else
echo "ERROR: 降级 npm install --legacy-peer-deps 也失败(exit=${FALLBACK_EXIT}),请检查 Runner 网络是否能访问 ${NPM_REGISTRY_PRIMARY} / ${NPM_REGISTRY_FALLBACK}" >&2
echo " 可在 Variables 中覆盖 NPM_REGISTRY_PRIMARY / NPM_REGISTRY_FALLBACK,或在 Runner 宿主机手动配 npm 代理" >&2
echo " 快速自检(在 Runner 宿主机执行):node -v && npm -v && npm view vue version --registry=${NPM_REGISTRY_PRIMARY}" >&2
exit 1
fi
fi
if [ ! -f "${CACHE_FILE}" ]; then
tar -czf "${CACHE_FILE}" node_modules
echo "[npm cache] 已写入缓存: ${CACHE_FILE}"
else
echo "[npm cache] 已存在缓存,跳过重复写入: ${CACHE_FILE}"
fi
fi
- name: 检查 package.json 无 pnpm 残留
run: |
set -euo pipefail
grep -qE '"packageManager"\s*:\s*"pnpm' package.json && { echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止"; exit 1; }; echo "package.json 未发现 pnpm 声明"
- name: 执行 nuxt build
env:
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
run: |
set -euxo pipefail
echo "=== 构建前系统内存状态 ==="
free -h || true
sync && echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true
echo "=== 开始执行 nuxt build ==="
export NODE_OPTIONS="--max-old-space-size=10240 --no-warnings --expose-gc"
export NITRO_PRESET=node-server
export NITRO_MINIFY=false
export DISABLE_ROLLDOWN=1
export OAUTH_REDIRECT_URI="${OAUTH_REDIRECT_URI:-}"
export OAUTH_STATE_SECRET="${OAUTH_STATE_SECRET:-}"
export AGGREGATE_OAUTH_APP_ID="${AGGREGATE_OAUTH_APP_ID:-}"
export AGGREGATE_OAUTH_APP_KEY="${AGGREGATE_OAUTH_APP_KEY:-}"
export AGGREGATE_OAUTH_LOGIN_TYPE="${AGGREGATE_OAUTH_LOGIN_TYPE:-}"
export AGGREGATE_OAUTH_ENDPOINT="${AGGREGATE_OAUTH_ENDPOINT:-}"
if [ -n "${AGGREGATE_OAUTH_APP_ID}" ]; then
echo "[OAuth] build-time AGGREGATE_OAUTH_APP_ID 已配置(前端第三方登录按钮会显示), LOGIN_TYPE=${AGGREGATE_OAUTH_LOGIN_TYPE:-<unset>}"
else
echo "[OAuth] build-time AGGREGATE_OAUTH_APP_ID 未配置——前端第三方登录将显示占位提示,请先在 Repository Secrets 中配置 AGGREGATE_OAUTH_APP_ID"
fi
npm run build
echo "=== 构建完成,.output/ 生成结果 ==="
ls -la .output/ 2>/dev/null || true
du -sh .output 2>/dev/null || true
echo "=== 构建后内存状态 ==="
free -h || true
meta-warmup:
name: 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化)
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
image_full: ${{ steps.meta.outputs.full }}
image_latest: ${{ steps.meta.outputs.latest }}
repo_owner: ${{ steps.meta.outputs.repo_owner }}
repo_name: ${{ steps.meta.outputs.repo_name }}
steps:
- name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
run: |
set -euo pipefail
# 只在真的缺包时才跑 apt-get update(Runner 通常自带 git/curl/ca-certificates/tar/xz,99% 场景直接跳过)
NEED_APT_UPDATE=0
need_cmd() {
local cmd="$1"
if ! command -v "${cmd}" >/dev/null 2>&1; then
NEED_APT_UPDATE=1
return 1
fi
return 0
}
need_pkg() {
local pkg="$1"
if command -v dpkg >/dev/null 2>&1; then
if dpkg -s "${pkg}" >/dev/null 2>&1; then
return 0
fi
NEED_APT_UPDATE=1
return 1
fi
return 0
}
NEED_GIT=0; need_cmd git || NEED_GIT=1
NEED_CURL=0; need_cmd curl || NEED_CURL=1
NEED_CA=0; need_pkg ca-certificates || NEED_CA=1
NEED_TAR=0; need_cmd tar || NEED_TAR=1
NEED_XZ=0; (need_cmd xz || need_cmd unxz) || NEED_XZ=1
APT_MISSING=()
[ "${NEED_GIT}" = "1" ] && APT_MISSING+=("git")
[ "${NEED_CURL}" = "1" ] && APT_MISSING+=("curl")
[ "${NEED_CA}" = "1" ] && APT_MISSING+=("ca-certificates")
[ "${NEED_TAR}" = "1" ] && APT_MISSING+=("tar")
[ "${NEED_XZ}" = "1" ] && APT_MISSING+=("xz-utils")
if [ "${#APT_MISSING[@]}" -gt 0 ]; then
echo "[apt] 检测到缺包: ${APT_MISSING[*]},执行 apt-get update + install"
if command -v sudo >/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 "${APT_MISSING[@]}" 2>/dev/null || \
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
else
apt-get update -qq 2>/dev/null || true
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
fi
else
echo "[apt] 所有基础命令/包已存在,跳过 apt-get update(节省 30-90s)"
fi
if command -v update-ca-certificates >/dev/null 2>&1; then
sudo update-ca-certificates 2>/dev/null || update-ca-certificates 2>/dev/null || true
fi
# 镜像源替换:只在命中官方 archive.ubuntu.com / security.ubuntu.com 时才 sed,避免破坏 Runner 已配好的公司/国内镜像
APT_SOURCES_HIT=0
if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then
if grep -qE 'archive\.ubuntu\.com|security\.ubuntu\.com' /etc/apt/sources.list 2>/dev/null; then
APT_SOURCES_HIT=1
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
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
if grep -qE 'archive\.ubuntu\.com|security\.ubuntu\.com' "${f}" 2>/dev/null; then
APT_SOURCES_HIT=1
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
fi
done
fi
if [ "${APT_SOURCES_HIT}" = "1" ]; then
echo "[apt] 检测到官方 Ubuntu 源域名,已替换为清华镜像,下次缺包 install 会走国内源"
else
echo "[apt] 未在 sources(.list.d) 中发现官方 archive.ubuntu.com 域名,跳过镜像源替换(保留 Runner 原有配置)"
fi
sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
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
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: 注入 OAuth secrets 到 GITHUB_ENV
env:
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
run: |
set -euo pipefail
OAUTH_ENV_FILE="$GITHUB_ENV"
_append_env() {
local K="$1" V="$2"
if [ -n "${V}" ]; then
printf '%s="%s"\n' "$K" "$V" >> "$OAUTH_ENV_FILE"
echo "[OAuth-secrets] $K=已注入 (len=${#V})"
else
echo "[OAuth-secrets] $K=未配置(Repository Secrets 中缺失或为空)"
fi
}
_append_env OAUTH_REDIRECT_URI "${OAUTH_REDIRECT_URI:-}"
_append_env OAUTH_STATE_SECRET "${OAUTH_STATE_SECRET:-}"
_append_env AGGREGATE_OAUTH_APP_ID "${AGGREGATE_OAUTH_APP_ID:-}"
_append_env AGGREGATE_OAUTH_APP_KEY "${AGGREGATE_OAUTH_APP_KEY:-}"
_append_env AGGREGATE_OAUTH_LOGIN_TYPE "${AGGREGATE_OAUTH_LOGIN_TYPE:-}"
_append_env AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT:-}"
echo "[OAuth-secrets] build-image 作业写入完成;docker build 可作为 build-arg 透传到 nuxt build"
- name: 计算镜像标签
id: meta
run: |
set -euo pipefail
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
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
run: |
set -euxo pipefail
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 '[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'
printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
} > "${BKK_CFG}"
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; }
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
-u "${REGISTRY_USER}" --password-stdin
echo "宿主 Docker 登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
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}"
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: 确认已登录
run: |
set -euo pipefail
docker info 2>/dev/null | grep -E "Username|Registry" || echo "已存在登录凭据(跳过详细输出)"
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
build-image:
name: 构建并推送容器镜像
needs: [preflight, meta-warmup]
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image_tag: ${{ needs.meta-warmup.outputs.image_tag }}
image_full: ${{ needs.meta-warmup.outputs.image_full }}
image_latest: ${{ needs.meta-warmup.outputs.image_latest }}
steps:
- name: 检出代码
run: |
set -euxo pipefail
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_REF_NAME:-main}"
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
- name: 初始化 Docker Buildx builder (为本作业重新创建)
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
run: |
set -euxo pipefail
if [ -z "${REGISTRY_TOKEN}" ]; then
echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN"
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 '[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'
printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
} > "${BKK_CFG}"
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 基础镜像"; exit 1; }
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" -u "${REGISTRY_USER}" --password-stdin
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}"
docker buildx inspect --bootstrap
- name: 复用 meta-warmup 结果(与 Preflight 并发省 ~5min)
run: |
set -euo pipefail
[ -n "${{ needs.meta-warmup.outputs.image_full }}" ] || { echo "ERROR: meta-warmup outputs.image_full 为空"; exit 1; }
[ -n "${{ needs.meta-warmup.outputs.image_latest }}" ] || { echo "ERROR: meta-warmup outputs.image_latest 为空"; exit 1; }
[ -n "${{ needs.meta-warmup.outputs.image_tag }}" ] || { echo "ERROR: meta-warmup outputs.image_tag 为空"; exit 1; }
[ -n "${{ needs.meta-warmup.outputs.repo_owner }}" ] || { echo "ERROR: meta-warmup outputs.repo_owner 为空"; exit 1; }
[ -n "${{ needs.meta-warmup.outputs.repo_name }}" ] || { echo "ERROR: meta-warmup outputs.repo_name 为空"; exit 1; }
echo "[meta-warmup] image_tag=${{ needs.meta-warmup.outputs.image_tag }}"
echo "[meta-warmup] image_full=${{ needs.meta-warmup.outputs.image_full }}"
echo "[meta-warmup] image_latest=${{ needs.meta-warmup.outputs.image_latest }}"
echo "[meta-warmup] repo_owner=${{ needs.meta-warmup.outputs.repo_owner }}"
echo "[meta-warmup] repo_name=${{ needs.meta-warmup.outputs.repo_name }}"
- name: 构建并推送镜像
env:
SKIP_CACHE: ${{ inputs.skip_build_cache }}
IMG_FULL: ${{ needs.meta-warmup.outputs.image_full }}
IMG_LATEST: ${{ needs.meta-warmup.outputs.image_latest }}
REPO_OWNER: ${{ needs.meta-warmup.outputs.repo_owner }}
REPO_NAME: ${{ needs.meta-warmup.outputs.repo_name }}
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)}"
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}"
)
if [ -n "${OAUTH_REDIRECT_URI:-}" ]; then BUILD_ARGS+=( --build-arg OAUTH_REDIRECT_URI="${OAUTH_REDIRECT_URI}" ); fi
if [ -n "${OAUTH_STATE_SECRET:-}" ]; then BUILD_ARGS+=( --build-arg OAUTH_STATE_SECRET="${OAUTH_STATE_SECRET}" ); fi
if [ -n "${AGGREGATE_OAUTH_APP_ID:-}" ]; then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_APP_ID="${AGGREGATE_OAUTH_APP_ID}" ); fi
if [ -n "${AGGREGATE_OAUTH_APP_KEY:-}" ];then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_APP_KEY="${AGGREGATE_OAUTH_APP_KEY}" ); fi
if [ -n "${AGGREGATE_OAUTH_LOGIN_TYPE:-}" ]; then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_LOGIN_TYPE="${AGGREGATE_OAUTH_LOGIN_TYPE}" ); fi
if [ -n "${AGGREGATE_OAUTH_ENDPOINT:-}" ]; then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_ENDPOINT="${AGGREGATE_OAUTH_ENDPOINT}" ); fi
echo "[OAuth build-args] OAUTH_REDIRECT_URI=$( [ -n "${OAUTH_REDIRECT_URI:-}" ] && echo 已注入 || echo 空 )"
echo "[OAuth build-args] OAUTH_STATE_SECRET=$( [ -n "${OAUTH_STATE_SECRET:-}" ] && echo 已注入 || echo 空 )"
echo "[OAuth build-args] AGGREGATE_OAUTH_APP_ID=$( [ -n "${AGGREGATE_OAUTH_APP_ID:-}" ] && echo 已注入 || echo 空 )"
echo "[OAuth build-args] AGGREGATE_OAUTH_APP_KEY=$( [ -n "${AGGREGATE_OAUTH_APP_KEY:-}" ] && echo 已注入 || echo 空 )"
echo "[OAuth build-args] AGGREGATE_OAUTH_LOGIN_TYPE=$( [ -n "${AGGREGATE_OAUTH_LOGIN_TYPE:-}" ] && echo 已注入 || echo 空 )"
echo "[OAuth build-args] AGGREGATE_OAUTH_ENDPOINT=$( [ -n "${AGGREGATE_OAUTH_ENDPOINT:-}" ] && echo 已注入 || echo 空 )"
if [ "${SKIP_CACHE}" = "true" ]; then
BUILD_ARGS+=( --no-cache )
echo "已启用强制重新构建(inputs.skip_build_cache=true,跳过 BuildKit 分层缓存)"
else
echo "使用 BuildKit 分层缓存加速(默认不写 --no-cache)"
fi
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}" \
.
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}'
echo "开始推送:${IMG_FULL}"
docker push "${IMG_FULL}"
echo "开始推送:${IMG_LATEST}"
docker push "${IMG_LATEST}"
echo "推送完成:2 个 tag(date-sha + latest)"
- name: 镜像体积与层信息
env:
IMG_FULL: ${{ needs.meta-warmup.outputs.image_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: [preflight, build-image]
if: always() && needs.preflight.result == 'success' && needs.build-image.result == 'success' && (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 }}
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
WECHAT_MINI_APPID: ${{ secrets.WECHAT_MINI_APPID }}
WECHAT_MINI_SECRET: ${{ secrets.WECHAT_MINI_SECRET }}
# 可选:微信订阅消息模板(JSON)。不设也没关系 —— 驳回通知的模板已内置在
# server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES 里;
# 只有"换模板 / 再加一种通知"时才需要在这里给一个 Secret。
# ⚠️ 少了这一行映射,下面 upsert_env_key 拿到的永远是空值(设了 Secret 也不生效)。
WECHAT_MINI_SUBSCRIBE_TEMPLATES: ${{ secrets.WECHAT_MINI_SUBSCRIBE_TEMPLATES }}
run: |
set -euo pipefail
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,,}"
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 "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
ENV_SH="/tmp/deploy_env_$$"
ENV_PAYLOAD="$(printf 'export IMAGE_NEW=%q\nexport IMAGE_LATEST=%q\nexport DEPLOY_PATH=%q\nexport GITEA_HOST=%q\nexport GH_PROXY_PREFIX=%q\nexport REGISTRY_PULL_USER=%q\nexport REGISTRY_PULL_TOKEN=%q\nexport IMAGE_NAME=%q\nexport NUXT_PUBLIC_SITE_URL=%q\nexport GITHUB_SERVER_URL=%q\nexport GITHUB_REPOSITORY=%q\nexport GITHUB_REF_NAME=%q\nexport OAUTH_REDIRECT_URI=%q\nexport OAUTH_STATE_SECRET=%q\nexport AGGREGATE_OAUTH_APP_ID=%q\nexport AGGREGATE_OAUTH_APP_KEY=%q\nexport AGGREGATE_OAUTH_LOGIN_TYPE=%q\nexport AGGREGATE_OAUTH_ENDPOINT=%q\nexport WECHAT_MINI_APPID=%q\nexport WECHAT_MINI_SECRET=%q\nexport WECHAT_MINI_SUBSCRIBE_TEMPLATES=%q\n' \
"${IMAGE_NEW}" "${IMAGE_LATEST}" "${DEPLOY_PATH}" "${GITEA_HOST}" "${GH_PROXY_PREFIX}" \
"${REGISTRY_PULL_USER}" "${REGISTRY_PULL_TOKEN}" "${IMAGE_NAME}" "${NUXT_PUBLIC_SITE_URL}" \
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_REF_NAME}" \
"${OAUTH_REDIRECT_URI}" "${OAUTH_STATE_SECRET}" \
"${AGGREGATE_OAUTH_APP_ID}" "${AGGREGATE_OAUTH_APP_KEY}" \
"${AGGREGATE_OAUTH_LOGIN_TYPE}" "${AGGREGATE_OAUTH_ENDPOINT}" \
"${WECHAT_MINI_APPID}" "${WECHAT_MINI_SECRET}" \
"${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}")"
printf '%s' "${ENV_PAYLOAD}" | ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}"
echo "[Deploy] env script written to ${ENV_SH} on remote"
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "set -e; ls -la ${ENV_SH}; grep -q IMAGE_NEW ${ENV_SH} && echo '[Deploy] env_sh IMAGE_NEW line OK'"
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- '${ENV_SH}'" <<'REMOTE_SCRIPT_MAIN'
set -euxo pipefail
ENV_SH="$1"
if [ -f "$ENV_SH" ]; then
. "$ENV_SH"
rm -f "$ENV_SH"
fi
DEPLOY_DIR="${DEPLOY_PATH}"
COMPOSE="docker compose"
echo "=============================================="
echo "Executor : $(whoami)"
echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
echo "=============================================="
if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
echo "ERROR: IMAGE_NEW or DEPLOY_DIR is empty — env variables not propagated correctly."
echo "IMAGE_NEW=${IMAGE_NEW}"
echo "DEPLOY_DIR=${DEPLOY_DIR}"
exit 9
fi
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 "${AGGREGATE_OAUTH_APP_ID}" ] || [ -n "${WECHAT_MINI_APPID}" ]; then
ENV_FILE="${DEPLOY_DIR}/.env"
touch "${ENV_FILE}"
MERGE_OK=1
upsert_env_key() {
_K="$1"; _V="$2"
if [ -z "${_V}" ]; then return 0; fi
# 值的引号要自适应:JSON(含双引号)必须用**单引号**包,
# 否则 .env 会变成 KEY="{"a":"b"}" 这种坏行,docker compose 解析直接崩。
# 值里不可能同时出现两种引号(JSON 不含单引号),所以这样是安全的。
_Q='"'
case "${_V}" in
*'"'*) _Q="'" ;;
esac
_LINE="${_K}=${_Q}${_V}${_Q}"
if grep -q "^${_K}=" "${ENV_FILE}" 2>/dev/null; then
# 用 grep -v 重写而不是 awk 改字段:awk 的 FS="=" 会把值里 "=" 之后的内容丢掉
# (base64 密钥常带 "=" 填充,正好踩这个坑)
if grep -v "^${_K}=" "${ENV_FILE}" > "${ENV_FILE}.tmp.$$" 2>/dev/null; then
printf '%s\n' "${_LINE}" >> "${ENV_FILE}.tmp.$$"
mv "${ENV_FILE}.tmp.$$" "${ENV_FILE}" || { rm -f "${ENV_FILE}.tmp.$$"; MERGE_OK=0; }
else
rm -f "${ENV_FILE}.tmp.$$"
MERGE_OK=0
fi
else
printf '%s\n' "${_LINE}" >> "${ENV_FILE}"
fi
}
upsert_env_key OAUTH_REDIRECT_URI "${OAUTH_REDIRECT_URI}" || MERGE_OK=0
upsert_env_key OAUTH_STATE_SECRET "${OAUTH_STATE_SECRET}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_APP_ID "${AGGREGATE_OAUTH_APP_ID}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_APP_KEY "${AGGREGATE_OAUTH_APP_KEY}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_LOGIN_TYPE "${AGGREGATE_OAUTH_LOGIN_TYPE}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT}" || MERGE_OK=0
upsert_env_key WECHAT_MINI_APPID "${WECHAT_MINI_APPID}" || MERGE_OK=0
upsert_env_key WECHAT_MINI_SECRET "${WECHAT_MINI_SECRET}" || MERGE_OK=0
# 微信订阅消息模板(JSON)。仓库 Secret 里没设这项时 upsert_env_key 直接返回,
# 不会清掉服务器 .env 里手工配置的值。
# ⚠️ 变量必须同时在【本 step 的 env:】【ENV_PAYLOAD 的 export 列表】两处出现:
# 远端脚本是 `set -euxo pipefail`,只在一处声明 → 另一端就是 unbound variable,
# 整个部署会在这里挂掉(第 90 轮踩过)。这里再补 `:-` 兜底。
upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" || MERGE_OK=0
rm -f "${ENV_FILE}.tmp.$$"
if [ "${MERGE_OK}" = "1" ]; then
echo "[Runtime-env] 运行期配置已合并写入 ${ENV_FILE}(OAuth=${AGGREGATE_OAUTH_APP_ID:+已配置} WECHAT_MINI_APPID=${WECHAT_MINI_APPID:+已配置})"
else
echo "[OAuth] WARNING:运行期配置合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署"
fi
else
echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .env。如已在服务器手动配置过 .env 则无需担心。"
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 '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_MAIN
rm -f "${SSH_KEY_FILE}"