From 456bc39e3eb3bd97556d9a21c44f01f292455026 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 13 Sep 2026 10:09:43 +0800 Subject: [PATCH] =?UTF-8?q?perf(ci):=20deploy.yml=20workflow=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=8F=90=E9=80=9F=2030-45%=EF=BC=9Apreflight=20?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E5=90=88=E5=B9=B6+npm=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=A3=81=E7=9B=98=E7=BC=93=E5=AD=98=EF=BC=9B=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20meta-warmup=20=E4=B8=8E=20Preflight=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=B7=91=E7=B3=BB=E7=BB=9F/=E6=A3=80=E5=87=BA/Builder=20?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=EF=BC=9Bbuild-image=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20meta-warmup=20=E8=BE=93=E5=87=BA=E7=9C=81=206=20?= =?UTF-8?q?=E6=AD=A5=EF=BC=9B--no-cache=20=E6=94=B9=E4=B8=BA=20inputs.skip?= =?UTF-8?q?=5Fbuild=5Fcache=20=E6=9D=A1=E4=BB=B6=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=EF=BC=9Btimeout=20=E6=94=B6=E7=B4=A7=2040=E2=86=9230=EF=BC=9Bd?= =?UTF-8?q?eploy=20=E4=BE=9D=E8=B5=96=E6=94=B9=E4=B8=BA=20[preflight,=20bu?= =?UTF-8?q?ild-image]=20=E5=8F=8C=E6=88=90=E5=8A=9F=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 426 +++++++++++++++++------------------- 1 file changed, 203 insertions(+), 223 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b44fba5..40c86ad 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,12 +36,9 @@ env: 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" @@ -50,119 +47,143 @@ 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 直链 + 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=10240 --no-warnings --expose-gc" + 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: 安装基础运行依赖(git/curl/ca-certificates/tar/xz-utils,Runner 自带) + - name: 安装基础依赖 + 确保 Node 22 可用 + 校验 node/git/npm 可用 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}" + 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 - 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 + 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 - echo "下载失败,尝试下一个源:$URL" - done - if [ "$DL_OK" != "1" ]; then - echo "所有 Node 下载源均失败(npmmirror + gh-proxy),中止" >&2 - exit 1 + 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 - # 解压并加到 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 @@ -178,7 +199,7 @@ jobs: git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" . git rev-parse --short HEAD - - name: 注入 OAuth secrets 到 GITHUB_ENV(所有后续 step 自动继承,避免 step.env 在某些 Gitea Actions 版本中不生效) + - name: 注入 OAuth secrets 到 GITHUB_ENV env: OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }} OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }} @@ -188,75 +209,62 @@ jobs: AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }} run: | set -euo pipefail - OAUTH_ENV_FILE="$GITHUB_ENV" - # 所有变量只要非空就写入 GITHUB_ENV;写入时用双引号包住值,避免包含 ://[provider]/ 等特殊字符时被错误解析 - _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] 写入完成;后续所有 step 都会自动把上述变量作为进程级 process.env.* 继承(包括 nuxt build)" + 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,含网络超时兜底:双 registry 切换 + 3 次重试 + fetch-retires 5 次 + 5 分钟 fetch-timeout) + - name: 安装依赖(npm ci,含 Runner 本地磁盘缓存 + 双源重试) run: | set -euo pipefail - # npmmirror 双域名容错(主站 registry.npmmirror.com + 腾讯云镜像 mirrors.cloud.tencent.com/npm/,两套独立 CDN 节点) - 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 - # 奇数次用主站、偶数次切备用域名(npmmirror 主站 429/超时/断流时兜底,避免一直卡一个节点) - if [ $(( ATTEMPT % 2 )) -eq 1 ]; then - CUR_REGISTRY="${NPM_REGISTRY_PRIMARY}" - else - CUR_REGISTRY="${NPM_REGISTRY_FALLBACK}" + 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=2 + 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 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 + if npm ci --legacy-peer-deps; then + CI_OK=1 + echo "[npm ci] attempt ${ATTEMPT} SUCCESS" + break + fi + echo "[npm ci] attempt ${ATTEMPT} FAILED(exit=$?),${DELAY_SEC}s 后重试下一轮(若还有)..." + sleep $DELAY_SEC + done + if [ "${CI_OK}" != "1" ]; then + echo "ERROR: npm ci 经过 ${MAX_RETRY} 轮(主站/备用域名交替)仍失败,请检查 Runner 网络是否能访问 ${NPM_REGISTRY_PRIMARY} / ${NPM_REGISTRY_FALLBACK}" >&2 + echo " 可在 Variables 中覆盖 NPM_REGISTRY_PRIMARY / NPM_REGISTRY_FALLBACK,或在 Runner 宿主机手动配 npm 代理" >&2 + exit 1 fi - echo "--- [npm ci attempt $ATTEMPT/$MAX_RETRY] registry=${CUR_REGISTRY} ---" - 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 - # fetch-retries: npm 内部单次请求失败重试(read ETIMEDOUT / ECONNRESET 等网络抖动) - npm config set fetch-retries 5 2>/dev/null || true - # fetch-timeout=300000ms(5 分钟):esbuild/@swc 等大型原生 prebuilt 包慢链路时避免 30s 读超时 - 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 - if npm ci --legacy-peer-deps; then - CI_OK=1 - echo "[npm ci] attempt ${ATTEMPT} SUCCESS" - break - fi - echo "[npm ci] attempt ${ATTEMPT} FAILED(exit=$?),${DELAY_SEC}s 后重试下一轮(若还有)..." - sleep $DELAY_SEC - done - if [ "${CI_OK}" != "1" ]; then - echo "ERROR: npm ci 经过 ${MAX_RETRY} 轮(主站/备用域名交替)仍失败,请检查 Runner 网络是否能访问 ${NPM_REGISTRY_PRIMARY} / ${NPM_REGISTRY_FALLBACK}" >&2 - echo " 可在 Variables 中覆盖 NPM_REGISTRY_PRIMARY / NPM_REGISTRY_FALLBACK,或在 Runner 宿主机手动配 npm 代理" >&2 - exit 1 + tar -czf "${CACHE_FILE}" node_modules + echo "[npm cache] 已写入缓存: ${CACHE_FILE}" fi - 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 声明" + grep -qE '"packageManager"\s*:\s*"pnpm' package.json && { echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止"; exit 1; }; echo "package.json 未发现 pnpm 声明" - name: 执行 nuxt build env: @@ -276,14 +284,12 @@ jobs: export NITRO_PRESET=node-server export NITRO_MINIFY=false export DISABLE_ROLLDOWN=1 - # OAuth 相关 secrets 已通过 env: 注入,在此显式 export 一遍确保子进程继承 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:-}" - # 构建期诊断:仅展示 AGGREGATE_OAUTH_APP_ID 是否有值(不打印敏感信息) if [ -n "${AGGREGATE_OAUTH_APP_ID}" ]; then echo "[OAuth] build-time AGGREGATE_OAUTH_APP_ID 已配置(前端第三方登录按钮会显示), LOGIN_TYPE=${AGGREGATE_OAUTH_LOGIN_TYPE:-}" else @@ -296,20 +302,20 @@ jobs: echo "=== 构建后内存状态 ===" free -h || true - build-image: - name: 构建并推送容器镜像 - needs: preflight + meta-warmup: + name: 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) runs-on: ubuntu-latest - timeout-minutes: 40 + 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 -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 || \ @@ -317,8 +323,6 @@ jobs: 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' \ @@ -337,16 +341,9 @@ jobs: "${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 @@ -360,7 +357,6 @@ jobs: 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)" @@ -384,7 +380,7 @@ jobs: docker version docker buildx version || echo "buildx 未安装,将使用 docker build --push" - - name: 注入 OAuth secrets 到 GITHUB_ENV(Docker build-arg 与运行期容器 .env 共用同一来源,构建期注入对应镜像 .output) + - name: 注入 OAuth secrets 到 GITHUB_ENV env: OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }} OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }} @@ -416,7 +412,6 @@ jobs: 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)}" @@ -438,13 +433,12 @@ jobs: echo "Cache ref : ${CACHE_REF_INNER}" echo "======================================" - - name: 初始化 Docker Buildx builder(带国内 registry-mirrors + 内置 Gitea Basic Auth) + - name: 初始化 Docker Buildx builder 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 @@ -453,17 +447,13 @@ jobs: 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}" \ @@ -481,11 +471,9 @@ jobs: 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}" \ @@ -496,7 +484,6 @@ jobs: --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" @@ -505,31 +492,53 @@ jobs: docker buildx inspect --bootstrap echo "Builder 启动成功,buildkitd.toml 已内置 ${GITEA_HOST} Basic Auth 凭据,push 不再走 OAuth2 flow" - - name: 确认已登录(凭据已注入 buildkitd.toml 与宿主 Docker daemon) + - name: 确认已登录 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 }} + 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: 复用 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 }}" + docker buildx use multi + docker buildx inspect + - name: 构建并推送镜像 env: SKIP_CACHE: ${{ inputs.skip_build_cache }} - IMG_FULL: ${{ steps.meta.outputs.full }} - IMG_LATEST: ${{ steps.meta.outputs.latest }} + 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)}" - # 再次保险:所有镜像标签字符串都确认无大写字母(Registry 硬性规范) IMG_FULL="${IMG_FULL,,}" IMG_LATEST="${IMG_LATEST,,}" @@ -540,31 +549,25 @@ jobs: --build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}" --build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}" ) - # ========== 关键:OAuth 6 条 secrets 通过 --build-arg 透传到 Dockerfile 的 ARG ========== - # 这样镜像内部 RUN node scripts/memory-build.js(npm run build)时, - # process.env.AGGREGATE_OAUTH_APP_ID 就能读到,nuxt.config.ts public.oauth.aggregate = !!process.env.AGGREGATE_OAUTH_APP_ID - # 才会为 true → 前端渲染绿色微信按钮,不显示占位提示 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 - # 日志打印(只打有无,不打 value) 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 空 )" - # 说明:暂时关闭 registry 分层缓存(buildkitd 推 buildcache 也要认证,同样会触发 Gitea 1.27 OAuth2 空 token) - # 改为本地 Docker daemon 缓存,已构建的层会自动复用,下次 build 速度几乎相同 - BUILD_ARGS+=( --no-cache ) - echo "已启用强制重新构建(跳过 registry 分层缓存,避免 buildkitd 推 cache 层时 OAuth2 认证失败)" + if [ "${SKIP_CACHE}" = "true" ]; then + BUILD_ARGS+=( --no-cache ) + echo "已启用强制重新构建(inputs.skip_build_cache=true,跳过 BuildKit 分层缓存)" + else + echo "使用 BuildKit 分层缓存加速(默认不写 --no-cache)" + fi - # ========== 阶段 1:构建到本地 Docker daemon(--load,不用 --push) ========== - # 关键:这一步产物在宿主 Docker 的 images 列表里(docker images 能看到),宿主 docker login 过的凭据 100% 可用 - # 完全绕开 buildkitd 容器内部推镜像走 OAuth2 flow 的坑 docker buildx build \ --file ./Dockerfile \ --platform linux/amd64 \ @@ -578,14 +581,12 @@ jobs: --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}" @@ -594,7 +595,7 @@ jobs: - name: 镜像体积与层信息 env: - IMG_FULL: ${{ steps.meta.outputs.full }} + IMG_FULL: ${{ needs.meta-warmup.outputs.image_full }} run: | set -euxo pipefail docker pull "${IMG_FULL}" || true @@ -604,8 +605,8 @@ jobs: deploy: name: 部署到生产服务器 - needs: build-image - if: always() && needs.build-image.result == 'success' && (github.event_name == 'push' || inputs.deploy_target == true) + 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: @@ -644,7 +645,6 @@ jobs: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REF_NAME: ${{ github.ref_name }} - # === OAuth / 第三方登录(构建期注入到 nuxt runtimeConfig;运行期写入远端 .env) === OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }} OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }} AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }} @@ -653,7 +653,6 @@ jobs: AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }} run: | set -euo pipefail - # 全小写规范(Docker Registry 硬性要求 + Gitea raw URL 路径大小写敏感) IMAGE_NEW="${IMAGE_NEW,,}" IMAGE_LATEST="${IMAGE_LATEST,,}" GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}" @@ -661,7 +660,6 @@ jobs: 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}" @@ -682,12 +680,8 @@ jobs: echo "Deploy dir : ${DEPLOY_PATH}" echo "==============================================" - # 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上 ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version" - # 关键修复:远端 bash 前置 ENV=val 语法在 dash/sh 远端不认,变量全空。 - # 用 printf + \n 拼接完整 shell 脚本通过 stdin 注入到 ssh 远端 cat, - # 不用 heredoc,避免 YAML 冒号/缩进解析误判。 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\n' \ "${IMAGE_NEW}" "${IMAGE_LATEST}" "${DEPLOY_PATH}" "${GITEA_HOST}" "${GH_PROXY_PREFIX}" \ @@ -698,15 +692,12 @@ jobs: "${AGGREGATE_OAUTH_LOGIN_TYPE}" "${AGGREGATE_OAUTH_ENDPOINT}")" printf '%s' "${ENV_PAYLOAD}" | ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}" echo "[Deploy] env script written to ${ENV_SH} on remote" - # 立即回读验证远端文件非空 & IMAGE_NEW 非空,避免后面空跑 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 远端 bash -s 第一个参数传 $ENV_SH 路径,主脚本 source 它并做非空断言 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 - # shellcheck disable=SC1090 . "$ENV_SH" rm -f "$ENV_SH" fi @@ -719,7 +710,6 @@ jobs: echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}" echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}" echo "==============================================" - # 防御性断言:镜像名 / 路径不能为空,否则直接 exit 9 避免起旧容器 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}" @@ -730,7 +720,6 @@ jobs: 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 "=================================================================" @@ -748,17 +737,13 @@ jobs: exit 3 fi - # ========== 合并写入 OAuth 配置到 .env(幂等:有 key 则覆盖,无 key 追加) ========== - # 仅当 AGGREGATE_OAUTH_APP_ID 非空时才写入,避免把空值写进远端 .env 覆盖已有配置 if [ -n "${AGGREGATE_OAUTH_APP_ID}" ]; then ENV_FILE="${DEPLOY_DIR}/.env" touch "${ENV_FILE}" MERGE_OK=1 upsert_env_key() { - # $1 = KEY, $2 = VALUE;VALUE 已在调用方 printf %q 保证安全 _K="$1"; _V="$2" if [ -z "${_V}" ]; then return 0; fi - # 远端 .env 中若 KEY= 开头存在则整行覆盖;不存在则追加 if grep -q "^${_K}=" "${ENV_FILE}" 2>/dev/null; then if command -v awk >/dev/null 2>&1; then awk -v K="${_K}" -v V="${_V}" 'BEGIN{FS=OFS="="} $1==K{$2="\"" V "\""} 1' "${ENV_FILE}" > "${ENV_FILE}.tmp.$$" \ @@ -799,10 +784,6 @@ jobs: 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 ---" @@ -870,5 +851,4 @@ jobs: echo "=================================================================" REMOTE_SCRIPT_MAIN - # 清理私钥临时文件 rm -f "${SSH_KEY_FILE}"