From 5962df638d2394071c7236506be9a8f75cec1343 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 11 Oct 2026 00:14:22 +0800 Subject: [PATCH] =?UTF-8?q?ci(deploy):=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E4=BB=8E=204=20=E4=B8=AA=20job=20=E7=B2=BE=E7=AE=80=E4=B8=BA?= =?UTF-8?q?=202=20=E4=B8=AA=EF=BC=8C=E5=B9=B6=E5=90=AF=E7=94=A8=20BuildKit?= =?UTF-8?q?=20registry=20=E6=9E=84=E5=BB=BA=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删掉 preflight(npm ci + nuxt build)与 meta-warmup:Dockerfile 自己在容器内 跑 npm ci + node scripts/memory-build.js,这两个 job 的产物从来没被 docker build 用到,纯白烧 3-6 分钟,meta-warmup 还在 build-image 之前抢同一个 runner 的内存。 - 删掉每个 job 各自跑的 apt 换源 / 清华镜像 / git URL 重写:对镜像构建没有任何影响。 - buildx 从「装不上就硬失败/一大段兜底脚本」简化为「装得上就用(带 registry 缓存), 装不上就退普通 docker build」,部署永不因 buildx 缺席而失败。 - 新增 BuildKit registry 缓存(--cache-from/--cache-to type=registry,tag :buildcache): 依赖安装层跨 run 复用,第二次之后的部署会快很多。 - 镜像标签改在 build-image 内计算(原先依赖 meta-warmup 的 outputs)。 - 新增 .gitattributes 强制 yml/sh 用 LF(core.autocrlf=true 会把 heredoc 写坏)。 --- .gitattributes | 9 + .gitea/workflows/deploy.yml | 1087 ++++++----------------------------- 2 files changed, 192 insertions(+), 904 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a37c243 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 强制 shell 脚本与工作流用 LF:runner 是 Linux,CRLF 会让 `run: |` 里的 heredoc / 多行脚本出问题 +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.ts text eol=lf +*.json text eol=lf +Dockerfile text eol=lf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 003c791..998be92 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,5 +1,14 @@ +# 精简版部署流水线(2 个 job) +# +# 精简前是 4 个 job:preflight(npm ci + nuxt build)、meta-warmup(系统环境准备 + 装 buildx)、 +# build-image(又装一次 buildx + docker build)、deploy。其中 preflight 和 meta-warmup 的产物 +# 从来没被 docker build 用到(Dockerfile:24-59 自己在容器里跑 npm ci + node scripts/memory-build.js, +# 用的是镜像自己的分层缓存),两个 job 各跑 3-6 分钟纯属白烧;meta-warmup 还在 build-image 之前 +# 用同一个 runner 抢内存。 +# +# 现在:build-image(构建+推送) → deploy(SSH 滚动部署)。构建缓存交给 BuildKit 的 +# registry 缓存(type=registry,缓存层存在 Gitea registry 的 :buildcache tag 里,跨 run 复用)。 name: 构建并部署 BYW VoiceHub -run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }} on: push: @@ -7,589 +16,69 @@ on: workflow_dispatch: inputs: deploy_target: - description: 构建完成后是否自动部署到服务器 + description: "只构建不部署时不要勾选" type: boolean default: true - required: false skip_build_cache: - description: 跳过 BuildKit 分层缓存,强制重新构建(排错用) + description: "跳过构建缓存(强制重新构建)" 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 + IMAGE_NAME: byw-voicehub DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/ NODE_BUILDER_TAG: "22-alpine" NODE_RUNTIME_TAG: "22-alpine" + 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 + DEPLOY_PATH: /data/opt/byw-voicehub + BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1 + # 构建缓存 tag(存在 Gitea registry 里,跨 run 复用) + CACHE_TAG: buildcache jobs: - preflight: - name: Preflight 语法与构建检查 + build-image: + name: 构建并推送镜像 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 + timeout-minutes: 40 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 }} + image_tag: ${{ steps.meta.outputs.image_tag }} + image_full: ${{ steps.meta.outputs.image_full }} + image_latest: ${{ steps.meta.outputs.image_latest }} steps: - - name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy) + - name: 准备 Docker(registry-mirrors) 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" + set -euxo pipefail 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 tee /etc/docker/daemon.json >/dev/null </dev/null \ || sudo service docker restart 2>/dev/null \ - || echo "[Docker] systemctl/service 重启失败,跳过(后续 buildx builder 内部仍有 registry mirrors)" - sleep 3 2>/dev/null || true + || echo "[Docker] 重启失败,继续(下面用显式 mirror 前缀拉镜像兜底)" + sleep 3 || true fi - - echo "环境准备完成" - cat /etc/os-release 2>/dev/null | head -4 || true docker --version - docker buildx version 2>&1 | head -1 || echo "buildx 子命令不可用" - - - name: 确保 Docker Buildx 插件可用(缺失则装官方插件,否则回退 docker build) - run: | - set -euxo pipefail - - # 症状:`docker buildx create --name xxx` 报 - # unknown flag: --name / See 'docker --help' - # 打出的是 docker 的总帮助 → 说明 `buildx` 子命令没落到真插件上,docker CLI 把 - # `--name` 当自己的顶层 flag 解析了。这里先探一次并按需装官方插件,不能等 build-image - # 作业才炸(各 job 是独立容器,装了也得在该 job 里再装一次)。 - ensure_buildx() { - # 不用 `docker buildx version` 当唯一依据:这种“子命令没落到真插件上”的坏状态里 - # 它仍可能返回 0。优先相信插件文件在位,其次才信命令。 - if [ -x "${HOME}/.docker/cli-plugins/docker-buildx" ] \ - || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] \ - || { docker buildx version >/dev/null 2>&1 && docker buildx create --help 2>/dev/null | grep -q -- '--name'; }; then - echo "[buildx] 插件可用:$(docker buildx version 2>&1 | head -1)" - return 0 - fi - echo "[buildx] 未发现可用插件,从官方 release 安装" - ARCH="$(uname -m)" - case "${ARCH}" in - x86_64|amd64) BUILDX_ARCH=amd64 ;; - aarch64|arm64) BUILDX_ARCH=arm64 ;; - *) echo "[buildx] 不支持的架构 ${ARCH},跳过安装"; return 1 ;; - esac - BUILDX_VER="v0.19.3" - BUILDX_BIN="buildx-${BUILDX_VER}.linux-${BUILDX_ARCH}" - BUILDX_DIR="${HOME}/.docker/cli-plugins" - mkdir -p "${BUILDX_DIR}" - if curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \ - "${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}" \ - || curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \ - "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}"; then - chmod +x "${BUILDX_DIR}/docker-buildx" - echo "[buildx] 已安装到 ${BUILDX_DIR}/docker-buildx" - else - rm -f "${BUILDX_DIR}/docker-buildx" - echo "[buildx] 下载失败(GitHub 与 ${GH_PROXY_PREFIX} 都不通)" - fi - if ! docker buildx version >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then - echo "[buildx] 当前 docker 不认 ~/.docker/cli-plugins,改写系统插件目录" - sudo mkdir -p /usr/local/lib/docker/cli-plugins - sudo cp -f "${BUILDX_DIR}/docker-buildx" /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true - sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true - fi - [ -x "${BUILDX_DIR}/docker-buildx" ] || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] - } - - if ensure_buildx && docker buildx create --help 2>/dev/null | grep -q -- '--name'; then - echo "[buildx] 自检通过(create 支持 --name)" - echo "BUILDX_INSTALLED=yes" >> "$GITHUB_ENV" - else - echo "[buildx] 自检未通过,镜像构建作业将自动回退 docker build(不阻塞部署)" - echo "BUILDX_INSTALLED=no" >> "$GITHUB_ENV" - fi + docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true - 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 + - name: 注入 OAuth secrets(构建期 build-arg) env: OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }} OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }} @@ -599,23 +88,21 @@ jobs: AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }} run: | set -euo pipefail - OAUTH_ENV_FILE="$GITHUB_ENV" - _append_env() { - local K="$1" V="$2" + 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" >> "$OAUTH_ENV_FILE" + printf '%s="%s"\n' "$K" "$V" >> "$GITHUB_ENV" echo "[OAuth-secrets] $K=已注入 (len=${#V})" else - echo "[OAuth-secrets] $K=未配置(Repository Secrets 中缺失或为空)" + echo "[OAuth-secrets] $K=未配置" 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" + done - name: 计算镜像标签 id: meta @@ -628,247 +115,105 @@ jobs: 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 "======================================" + CACHE_REF="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${CACHE_TAG}" + { + echo "image_tag=${DATE_TAG}" + echo "image_full=${IMG_FULL}" + echo "image_latest=${IMG_LATEST}" + echo "cache_ref=${CACHE_REF}" + } >> "$GITHUB_OUTPUT" echo "Image tag : ${DATE_TAG}" echo "Full image : ${IMG_FULL}" echo "Latest image : ${IMG_LATEST}" - echo "Cache ref : ${CACHE_REF_INNER}" - echo "======================================" + echo "Cache ref : ${CACHE_REF}" - - name: 初始化 Docker Buildx builder + - name: 安装 BuildKit builder(可选)+ 登录 registry + id: buildx env: REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }} + REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }} + REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }} run: | set -euxo pipefail - if [ -z "${REGISTRY_TOKEN}" ]; then - echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(个人应用令牌,权限 read:package + write:package + read:repository)" + REGISTRY_USER="${REGISTRY_USER,,}" + + # 兜底登录凭证:REGISTRY_* 优先,缺失则用 REGISTRY_PULL_* + LOGIN_USER="${REGISTRY_USER}" + LOGIN_TOKEN="${REGISTRY_TOKEN}" + if [ -z "${LOGIN_TOKEN}" ] || [ "${LOGIN_TOKEN}" = "null" ]; then + LOGIN_USER="${REGISTRY_PULL_USER:-}" + LOGIN_TOKEN="${REGISTRY_PULL_TOKEN:-}" + fi + if [ -z "${LOGIN_TOKEN}" ]; then + echo "ERROR: REGISTRY_TOKEN / REGISTRY_PULL_TOKEN 均为空,无法登录 ${GITEA_HOST}" 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}" \ + + # 先把 buildkitd 基础镜像拉到本地(多 mirror 轮询),装完插件后直接复用 + for M in \ + "${DOCKER_REGISTRY_MIRROR_1#https://}/${BUILDKIT_IMAGE}" \ + "${DOCKER_REGISTRY_MIRROR_2#https://}/${BUILDKIT_IMAGE}" \ + "${DOCKER_REGISTRY_MIRROR_3#https://}/${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 + if docker pull "${M}" 2>/dev/null; then + [ "${M}" = "${BUILDKIT_IMAGE}" ] || docker tag "${M}" "${BUILDKIT_IMAGE}" 2>/dev/null || true 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 }} + printf '%s' "${LOGIN_TOKEN}" | docker login "${GITEA_HOST}" -u "${LOGIN_USER}" --password-stdin - 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 插件可用(本作业容器内独立安装) - run: | - set -euxo pipefail - - # 每个 job 跑在独立容器里,meta-warmup 装的插件这里看不到,必须自己再确保一次。 - # 症状回顾:`docker buildx create --name xxx` 报 unknown flag: --name(打出的是 docker - # 总帮助)→ buildx 子命令没落到真插件上。装完仍不可用则不报错,交给后面的 docker build 兜底。 - # 判定同 meta-warmup:优先看插件文件是否在位,不把 `docker buildx version` 当唯一依据。 - if [ -x "${HOME}/.docker/cli-plugins/docker-buildx" ] \ - || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] \ - || { docker buildx version >/dev/null 2>&1 && docker buildx create --help 2>/dev/null | grep -q -- '--name'; }; then + # buildx 插件不是必须的:没有就走普通 docker build(仍是同一份 Dockerfile, + # 只是没有跨 run 的 registry 构建缓存)。绝不因为装不上而让部署失败。 + if docker buildx version >/dev/null 2>&1 \ + && docker buildx create --help 2>/dev/null | grep -q -- '--name'; then echo "[buildx] 插件已可用:$(docker buildx version 2>&1 | head -1)" - echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV" - exit 0 - fi - echo "[buildx] 未发现可用插件,从官方 release 安装" - ARCH="$(uname -m)" - case "${ARCH}" in - x86_64|amd64) BUILDX_ARCH=amd64 ;; - aarch64|arm64) BUILDX_ARCH=arm64 ;; - *) echo "[buildx] 不支持的架构 ${ARCH}"; echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"; exit 0 ;; - esac - BUILDX_VER="v0.19.3" - BUILDX_BIN="buildx-${BUILDX_VER}.linux-${BUILDX_ARCH}" - BUILDX_DIR="${HOME}/.docker/cli-plugins" - mkdir -p "${BUILDX_DIR}" - if curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \ - "${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}" \ - || curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \ - "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}"; then - chmod +x "${BUILDX_DIR}/docker-buildx" - echo "[buildx] 已安装到 ${BUILDX_DIR}/docker-buildx" else - rm -f "${BUILDX_DIR}/docker-buildx" - echo "[buildx] 下载失败(GitHub 与 ${GH_PROXY_PREFIX} 都不通)" + echo "[buildx] 未发现可用插件,尝试安装官方 buildx" + ARCH="$(uname -m)" + case "${ARCH}" in + x86_64|amd64) BX_ARCH=amd64 ;; + aarch64|arm64) BX_ARCH=arm64 ;; + *) BX_ARCH="" ;; + esac + if [ -n "${BX_ARCH}" ]; then + BX_VER="v0.19.3" + BX_DIR="${HOME}/.docker/cli-plugins" + mkdir -p "${BX_DIR}" + curl -fsSL --retry 3 --connect-timeout 20 -o "${BX_DIR}/docker-buildx" \ + "${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BX_VER}/buildx-${BX_VER}.linux-${BX_ARCH}" \ + || curl -fsSL --retry 3 --connect-timeout 20 -o "${BX_DIR}/docker-buildx" \ + "https://github.com/docker/buildx/releases/download/${BX_VER}/buildx-${BX_VER}.linux-${BX_ARCH}" \ + || rm -f "${BX_DIR}/docker-buildx" + [ -f "${BX_DIR}/docker-buildx" ] && chmod +x "${BX_DIR}/docker-buildx" || true + fi fi - if ! docker buildx version >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then - sudo mkdir -p /usr/local/lib/docker/cli-plugins - sudo cp -f "${BUILDX_DIR}/docker-buildx" /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true - sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true - fi - if [ -x "${BUILDX_DIR}/docker-buildx" ] || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ]; then - if docker buildx create --help 2>/dev/null | grep -q -- '--name'; then - echo "[buildx] 自检通过" + + if docker buildx version >/dev/null 2>&1 \ + && docker buildx create --help 2>/dev/null | grep -q -- '--name'; then + docker buildx rm multi 2>/dev/null || true + if docker buildx create --name multi --driver docker-container --use \ + --driver-opt network=host \ + --driver-opt "image=${BUILDKIT_IMAGE}" \ + && docker buildx inspect --bootstrap >/dev/null 2>&1; then echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV" + echo "[buildx] builder multi 就绪(启用 registry 构建缓存)" else - echo "[buildx] 插件在位但 create 仍不认识 --name,改用 docker build 兜底" echo "BUILDX_USABLE=no" >> "$GITHUB_ENV" + echo "[buildx] builder 创建失败,转 docker build 兜底" fi else - echo "[buildx] 安装未成功,改用 docker build 兜底" echo "BUILDX_USABLE=no" >> "$GITHUB_ENV" + echo "[buildx] 插件不可用,转 docker build 兜底(无跨 run 缓存)" fi - - name: 初始化 Docker Buildx builder (为本作业重新创建,不可用则回退 docker build) - 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 - - # buildx 插件不可用时(`docker buildx create --name` 报 unknown flag: --name, - # 说明子命令没落到真插件上),不再硬失败,转用普通 docker build 继续部署。 - if [ "${BUILDX_USABLE:-no}" != "yes" ] \ - || ! docker buildx version >/dev/null 2>&1 \ - || ! docker buildx create --help 2>/dev/null | grep -q -- '--name'; then - echo "[buildx] 本作业不可用,跳过 builder 初始化,将由 docker build 回退构建" - echo "BUILDX_USABLE=no" >> "$GITHUB_ENV" - rm -f "${BKK_CFG}" - exit 0 - fi - - docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true - if 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}" \ - && docker buildx inspect --bootstrap; then - echo "[buildx] builder ${BUILDER_NAME} 就绪,使用 buildx 构建(含分层缓存)" - echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV" - else - echo "[buildx] builder 创建或启动失败,回退 docker build(不使用分层缓存)" - echo "BUILDX_USABLE=no" >> "$GITHUB_ENV" - fi - rm -f "${BKK_CFG}" - - - 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 }} + IMG_FULL: ${{ steps.meta.outputs.image_full }} + IMG_LATEST: ${{ steps.meta.outputs.image_latest }} + CACHE_REF: ${{ steps.meta.outputs.cache_ref }} OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }} OCS_REVISION: ${{ github.sha }} OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }} @@ -878,6 +223,7 @@ jobs: OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}" IMG_FULL="${IMG_FULL,,}" IMG_LATEST="${IMG_LATEST,,}" + CACHE_REF="${CACHE_REF,,}" BUILD_ARGS=( --build-arg BUILDKIT_INLINE_CACHE=1 @@ -886,85 +232,67 @@ jobs: --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 + for V in OAUTH_REDIRECT_URI OAUTH_STATE_SECRET AGGREGATE_OAUTH_APP_ID \ + AGGREGATE_OAUTH_APP_KEY AGGREGATE_OAUTH_LOGIN_TYPE AGGREGATE_OAUTH_ENDPOINT; do + eval "VAL=\${${V}:-}" + if [ -n "${VAL}" ]; then + BUILD_ARGS+=( --build-arg "${V}=${VAL}" ) + echo "[build-arg] ${V}=已注入" + else + echo "[build-arg] ${V}=空" + fi + done - # BUILDX_USABLE 由「初始化 Docker Buildx builder」步骤写入 GITHUB_ENV: - # yes → buildx(分层缓存 + --load 打进本地 daemon) - # no → 普通 docker build 兜底(无分层缓存;依赖 daemon.json 的 registry-mirrors - # 或 Dockerfile 里的 DOCKER_MIRROR build-arg 拉基础镜像) - # 注意:--provenance 只有 buildx 认,回退时必须去掉。 - if [ "${BUILDX_USABLE:-yes}" = "yes" ]; then - echo "[build] 使用 docker buildx build" - 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}" \ - . + TAGS=( --tag "${IMG_FULL}" --tag "${IMG_LATEST}" ) + LABELS=( + --label "org.opencontainers.image.source=${OCS_SOURCE}" + --label "org.opencontainers.image.revision=${OCS_REVISION}" + --label "org.opencontainers.image.created=${OCS_CREATED}" + ) + + if [ "${BUILDX_USABLE:-no}" = "yes" ]; then + echo "[build] docker buildx build(含 registry 缓存)" + CACHE_ARGS=() + if [ "${SKIP_CACHE}" = "true" ]; then + echo "[build] skip_build_cache=true → 不使用缓存,仅更新缓存" + else + CACHE_ARGS+=( --cache-from "type=registry,ref=${CACHE_REF}" ) + fi + CACHE_ARGS+=( --cache-to "type=registry,ref=${CACHE_REF},mode=max" ) + if ! docker buildx build \ + --file ./Dockerfile \ + --platform linux/amd64 \ + --load \ + --provenance=false \ + "${BUILD_ARGS[@]}" "${CACHE_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" . ; then + echo "[build] 带 registry 缓存的构建失败(可能是本机 docker 版本不认 registry 缓存导出),去掉缓存重试一次" + docker buildx build \ + --file ./Dockerfile \ + --platform linux/amd64 \ + --load \ + --provenance=false \ + "${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" . + fi else - echo "[build] buildx 不可用,回退 docker build(生效的 mirror 由 daemon.json 决定)" - docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true + echo "[build] docker build 兜底(无构建缓存)" docker build \ --file ./Dockerfile \ --platform linux/amd64 \ - "${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}" \ - . + "${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" . fi 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 image inspect "${IMG_FULL}" --format='{{.Size}}' \ + | awk '{printf "镜像体积:%.2f MB\n", $1/1024/1024}' 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 + echo "推送完成:${IMG_FULL} + ${IMG_LATEST}" 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) + needs: [build-image] + if: needs.build-image.result == 'success' && (github.event_name == 'push' || inputs.deploy_target == true) runs-on: ubuntu-latest timeout-minutes: 25 environment: @@ -984,7 +312,7 @@ jobs: [ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY") if [ ${#MISSING[@]} -gt 0 ]; then echo "缺少以下 Repository Secrets:${MISSING[*]}" - echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)" + echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加" exit 1 fi echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})" @@ -1011,15 +339,7 @@ jobs: 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 }} - # 可选:酷狗凭证兜底(配一次永久用,不必进小程序粘贴 Cookie)。 - # 库里 bind-cookie 绑定的值优先;这里只在"库里没有"时生效。 - # ⚠️ 同样必须同时在【本 env:】【ENV_PAYLOAD 的 export 列表】两处出现, - # 远端脚本是 set -euxo pipefail,少一处就是 unbound variable 直接失败。 KUGOU_ADMIN_COOKIE: ${{ secrets.KUGOU_ADMIN_COOKIE }} KUGOU_ADMIN_TOKEN: ${{ secrets.KUGOU_ADMIN_TOKEN }} run: | @@ -1045,11 +365,9 @@ jobs: -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" @@ -1079,15 +397,11 @@ jobs: 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 @@ -1096,18 +410,14 @@ jobs: 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 @@ -1118,17 +428,15 @@ jobs: upsert_env_key() { _K="$1"; _V="$2" if [ -z "${_V}" ]; then return 0; fi - # 值的引号要自适应:JSON(含双引号)必须用**单引号**包, - # 否则 .env 会变成 KEY="{"a":"b"}" 这种坏行,docker compose 解析直接崩。 - # 值里不可能同时出现两种引号(JSON 不含单引号),所以这样是安全的。 + # 值的引号要自适应:JSON(含双引号)必须用单引号包,否则 .env 会变成坏行。 _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 密钥常带 "=" 填充,正好踩这个坑) + # 用 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; } @@ -1148,62 +456,45 @@ jobs: 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 轮踩过)。这里再补 `:-` 兜底。 + # 变量必须同时在【本 step 的 env:】【ENV_PAYLOAD 的 export 列表】两处出现: + # 远端脚本是 set -euxo pipefail,只在一处声明 → 另一端就是 unbound variable。 upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" || MERGE_OK=0 - # 酷狗凭证兜底(可选):配一次永久用;库里 bind-cookie 绑定的值优先。 - # 同样 `:-` 兜底,Secret 没配也不会因为 set -u 挂掉部署。 upsert_env_key KUGOU_ADMIN_COOKIE "${KUGOU_ADMIN_COOKIE:-}" || MERGE_OK=0 upsert_env_key KUGOU_ADMIN_TOKEN "${KUGOU_ADMIN_TOKEN:-}" || 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:+已配置})" + echo "[Runtime-env] 运行期配置已合并写入 ${ENV_FILE}" else - echo "[OAuth] WARNING:运行期配置合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署" + echo "[Runtime-env] WARNING:合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署" fi else - echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .env。如已在服务器手动配置过 .env 则无需担心。" + echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .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 + | 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 前缀)" + echo "docker-compose.yml 不存在,自动从仓库模板下载" 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)" + || echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env" 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 "=================================================================" + echo "WARNING:${DEPLOY_DIR}/.env 不存在(首次部署请参考仓库根目录 .env.example 创建)" fi echo "Executing docker compose up -d ..." @@ -1218,12 +509,7 @@ jobs: 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 + echo "${STATUS}" | grep -q '"State":"running"' || ${COMPOSE} ps || true sleep 5 WAITED=$((WAITED + 1)) done @@ -1237,15 +523,8 @@ jobs: 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 "=================================================================" + echo "Rollback: cd ${DEPLOY_DIR} && printf 'services:\\n voicehub:\\n image: \\n' > docker-compose.override.yml && docker compose up -d" REMOTE_SCRIPT_MAIN rm -f "${SSH_KEY_FILE}"