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:-}" 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: \\n' > docker-compose.override.yml" echo " docker compose up -d" echo "=================================================================" REMOTE_SCRIPT_MAIN rm -f "${SSH_KEY_FILE}"