订阅消息(小程序侧唯一能主动触达用户的通道): - 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、 phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行, 北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。 - wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID), 并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上 playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。 - 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。 - test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。 部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败): - 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连), 不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布, 不再因为缺插件整条流水线失败。 注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见 命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
1252 lines
69 KiB
YAML
1252 lines
69 KiB
YAML
name: 构建并部署 BYW VoiceHub
|
||
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
|
||
|
||
on:
|
||
push:
|
||
branches: [main, master]
|
||
workflow_dispatch:
|
||
inputs:
|
||
deploy_target:
|
||
description: 构建完成后是否自动部署到服务器
|
||
type: boolean
|
||
default: true
|
||
required: false
|
||
skip_build_cache:
|
||
description: 跳过 BuildKit 分层缓存,强制重新构建(排错用)
|
||
type: boolean
|
||
default: false
|
||
required: false
|
||
|
||
concurrency:
|
||
group: deploy-production
|
||
cancel-in-progress: true
|
||
|
||
env:
|
||
GITEA_HOST: gitea.fse-media.group
|
||
DEPLOY_PATH: /data/opt/byw-voicehub
|
||
IMAGE_NAME: byw-voicehub
|
||
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
|
||
APT_MIRROR_DEBIAN: https://mirrors.tuna.tsinghua.edu.cn/debian
|
||
APT_MIRROR_DEBIAN_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/debian-security
|
||
APT_MIRROR_DEBIAN_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian
|
||
APT_MIRROR_DEBIAN_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian-security
|
||
APT_MIRROR_UBUNTU: https://mirrors.tuna.tsinghua.edu.cn/ubuntu
|
||
APT_MIRROR_UBUNTU_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
|
||
APT_MIRROR_UBUNTU_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu
|
||
APT_MIRROR_UBUNTU_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
|
||
HTTP_PROXY_EXAMPLE: ""
|
||
HTTPS_PROXY_EXAMPLE: ""
|
||
DOCKER_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
|
||
DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
|
||
DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
|
||
DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
|
||
NODE_BUILDER_TAG: "22-alpine"
|
||
NODE_RUNTIME_TAG: "22-alpine"
|
||
|
||
jobs:
|
||
preflight:
|
||
name: Preflight 语法与构建检查
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 12
|
||
env:
|
||
NODE_MAJOR: "22"
|
||
NODE_VERSION_FALLBACK: "22.13.0"
|
||
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
|
||
NODE_OPTIONS: "--max-old-space-size=8192 --no-warnings --expose-gc"
|
||
NITRO_PRESET: "node-server"
|
||
DISABLE_ROLLDOWN: "1"
|
||
NITRO_MINIFY: "false"
|
||
VOICEHUB_NPM_CACHE_DIR: "${HOME}/.cache/voicehub-npm"
|
||
steps:
|
||
- name: 安装基础依赖 + 确保 Node 22 可用 + 校验 node/git/npm 可用
|
||
run: |
|
||
set -euxo pipefail
|
||
|
||
# 先统一探测:只有当下列命令/包任意一个缺失时,才统一执行 1 次 apt-get update + install
|
||
NEED_APT_UPDATE=0
|
||
need_cmd() {
|
||
local cmd="$1"
|
||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||
NEED_APT_UPDATE=1
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
need_pkg() {
|
||
local pkg="$1"
|
||
if command -v dpkg >/dev/null 2>&1; then
|
||
if dpkg -s "${pkg}" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
NEED_APT_UPDATE=1
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
NEED_GIT=0; need_cmd git || NEED_GIT=1
|
||
NEED_CURL=0; need_cmd curl || NEED_CURL=1
|
||
NEED_CA=0; need_pkg ca-certificates || NEED_CA=1
|
||
NEED_TAR=0; need_cmd tar || NEED_TAR=1
|
||
NEED_XZ=0; (need_cmd xz || need_cmd unxz) || NEED_XZ=1
|
||
|
||
APT_MISSING=()
|
||
[ "${NEED_GIT}" = "1" ] && APT_MISSING+=("git")
|
||
[ "${NEED_CURL}" = "1" ] && APT_MISSING+=("curl")
|
||
[ "${NEED_CA}" = "1" ] && APT_MISSING+=("ca-certificates")
|
||
[ "${NEED_TAR}" = "1" ] && APT_MISSING+=("tar")
|
||
[ "${NEED_XZ}" = "1" ] && APT_MISSING+=("xz-utils")
|
||
|
||
if [ "${#APT_MISSING[@]}" -gt 0 ]; then
|
||
echo "[apt][preflight] 检测到缺包: ${APT_MISSING[*]},执行 1 次 apt-get update + install"
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
sudo apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true
|
||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || \
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
|
||
else
|
||
apt-get update -qq 2>/dev/null || true
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
|
||
fi
|
||
else
|
||
echo "[apt][preflight] 所有基础命令/包已存在,跳过 apt-get update(节省 30-90s)"
|
||
fi
|
||
|
||
if command -v node >/dev/null 2>&1; then
|
||
CUR_NODE_MAJOR="$(node -v | tr -d 'v' | cut -d. -f1 || echo 0)"
|
||
if [ "${CUR_NODE_MAJOR:-0}" -ge "${NODE_MAJOR}" ]; then
|
||
echo "Runner 已自带 Node $(node -v)(≥ v${NODE_MAJOR}.x),直接复用,不下载"
|
||
node -v; npm -v
|
||
else
|
||
echo "Runner 自带 Node v${CUR_NODE_MAJOR} 不足 v${NODE_MAJOR}.x,手动升级"
|
||
ARCH="$(uname -m)"
|
||
case "$ARCH" in
|
||
x86_64|amd64) PLAT="linux-x64" ;;
|
||
aarch64|arm64) PLAT="linux-arm64" ;;
|
||
armv7l) PLAT="linux-armv7l" ;;
|
||
*) echo "不支持的架构:$ARCH" >&2; exit 1 ;;
|
||
esac
|
||
echo "目标架构:$PLAT"
|
||
INSTALL_DIR="${RUNNER_TEMP:-/tmp}/node-${NODE_MAJOR}"
|
||
mkdir -p "$INSTALL_DIR"
|
||
NODE_VER="${NODE_VERSION_FALLBACK}"
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdjAgPSBmInttYWpvcn0ue21pbm9yfS57cGF0Y2h9IgppZiBiZXN0X3Y6CiAgICBwcmludChiZXN0X3Yp"
|
||
REMOTE_LATEST=""
|
||
REMOTE_LATEST="$(curl -fsSL --max-time 20 "${NODE_MIRROR_PRIMARY}/index.json" 2>/dev/null \
|
||
| printf '%s' "$PY_NODE_B64" | base64 -d | python3 - "${NODE_MAJOR}" || true)"
|
||
if [ -n "$REMOTE_LATEST" ]; then
|
||
NODE_VER="$REMOTE_LATEST"
|
||
echo "npmmirror 查得 Node 22 latest: v${NODE_VER}"
|
||
else
|
||
echo "npmmirror index 查询失败,使用兜底: v${NODE_VER}"
|
||
fi
|
||
else
|
||
echo "python3 不可用,跳过 npmmirror 最新版本探测,使用兜底: v${NODE_VER}"
|
||
fi
|
||
TAR_XZ="node-v${NODE_VER}-${PLAT}.tar.xz"
|
||
mkdir -p /tmp/node-dl-cache
|
||
URL_1="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
|
||
URL_2="${GH_PROXY_PREFIX}https://nodejs.org/dist/v${NODE_VER}/${TAR_XZ}"
|
||
DL_OK=0
|
||
for URL in "$URL_1" "$URL_2"; do
|
||
echo "尝试下载:$URL"
|
||
if curl -fsSL --max-time 300 --retry 3 --retry-delay 2 -o "/tmp/node-dl-cache/${TAR_XZ}" "$URL"; then
|
||
echo "下载 OK:$URL"
|
||
DL_OK=1
|
||
break
|
||
fi
|
||
echo "下载失败,尝试下一个源:$URL"
|
||
done
|
||
if [ "$DL_OK" != "1" ]; then
|
||
echo "所有 Node 下载源均失败(npmmirror + gh-proxy),中止" >&2
|
||
exit 1
|
||
fi
|
||
tar -xJf "/tmp/node-dl-cache/${TAR_XZ}" -C "$INSTALL_DIR" --strip-components=1
|
||
if [ -f "$GITHUB_ENV" ]; then
|
||
echo "PATH=$INSTALL_DIR/bin:$PATH" >> "$GITHUB_ENV"
|
||
fi
|
||
export PATH="$INSTALL_DIR/bin:$PATH"
|
||
hash -r 2>/dev/null || true
|
||
node -v
|
||
npm -v
|
||
npm config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
|
||
fi
|
||
else
|
||
ARCH="$(uname -m)"
|
||
case "$ARCH" in
|
||
x86_64|amd64) PLAT="linux-x64" ;;
|
||
aarch64|arm64) PLAT="linux-arm64" ;;
|
||
armv7l) PLAT="linux-armv7l" ;;
|
||
*) echo "不支持的架构:$ARCH" >&2; exit 1 ;;
|
||
esac
|
||
echo "目标架构:$PLAT"
|
||
INSTALL_DIR="${RUNNER_TEMP:-/tmp}/node-${NODE_MAJOR}"
|
||
mkdir -p "$INSTALL_DIR"
|
||
NODE_VER="${NODE_VERSION_FALLBACK}"
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdjAgPSBmInttYWpvcn0ue21pbm9yfS57cGF0Y2h9IgppZiBiZXN0X3Y6CiAgICBwcmludChiZXN0X3Yp"
|
||
REMOTE_LATEST=""
|
||
REMOTE_LATEST="$(curl -fsSL --max-time 20 "${NODE_MIRROR_PRIMARY}/index.json" 2>/dev/null \
|
||
| printf '%s' "$PY_NODE_B64" | base64 -d | python3 - "${NODE_MAJOR}" || true)"
|
||
if [ -n "$REMOTE_LATEST" ]; then
|
||
NODE_VER="$REMOTE_LATEST"
|
||
echo "npmmirror 查得 Node 22 latest: v${NODE_VER}"
|
||
else
|
||
echo "npmmirror index 查询失败,使用兜底: v${NODE_VER}"
|
||
fi
|
||
else
|
||
echo "python3 不可用,跳过 npmmirror 最新版本探测,使用兜底: v${NODE_VER}"
|
||
fi
|
||
TAR_XZ="node-v${NODE_VER}-${PLAT}.tar.xz"
|
||
mkdir -p /tmp/node-dl-cache
|
||
URL_1="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
|
||
URL_2="${GH_PROXY_PREFIX}https://nodejs.org/dist/v${NODE_VER}/${TAR_XZ}"
|
||
DL_OK=0
|
||
for URL in "$URL_1" "$URL_2"; do
|
||
echo "尝试下载:$URL"
|
||
if curl -fsSL --max-time 300 --retry 3 --retry-delay 2 -o "/tmp/node-dl-cache/${TAR_XZ}" "$URL"; then
|
||
echo "下载 OK:$URL"
|
||
DL_OK=1
|
||
break
|
||
fi
|
||
echo "下载失败,尝试下一个源:$URL"
|
||
done
|
||
if [ "$DL_OK" != "1" ]; then
|
||
echo "所有 Node 下载源均失败(npmmirror + gh-proxy),中止" >&2
|
||
exit 1
|
||
fi
|
||
tar -xJf "/tmp/node-dl-cache/${TAR_XZ}" -C "$INSTALL_DIR" --strip-components=1
|
||
if [ -f "$GITHUB_ENV" ]; then
|
||
echo "PATH=$INSTALL_DIR/bin:$PATH" >> "$GITHUB_ENV"
|
||
fi
|
||
export PATH="$INSTALL_DIR/bin:$PATH"
|
||
hash -r 2>/dev/null || true
|
||
node -v
|
||
npm -v
|
||
npm config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
|
||
fi
|
||
|
||
git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
|
||
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
|
||
git --version
|
||
node -v
|
||
npm -v
|
||
|
||
- name: 检出代码
|
||
run: |
|
||
set -euxo pipefail
|
||
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
|
||
BRANCH="${GITHUB_REF_NAME:-main}"
|
||
echo "Clone from: ${REPO_URL} (${BRANCH})"
|
||
rm -rf * .[^.]* 2>/dev/null || true
|
||
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||
git rev-parse --short HEAD
|
||
|
||
- name: 注入 OAuth secrets 到 GITHUB_ENV
|
||
env:
|
||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
|
||
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
|
||
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
|
||
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
|
||
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
|
||
run: |
|
||
set -euo pipefail
|
||
for pair in "OAUTH_REDIRECT_URI:${OAUTH_REDIRECT_URI:-}" "OAUTH_STATE_SECRET:${OAUTH_STATE_SECRET:-}" "AGGREGATE_OAUTH_APP_ID:${AGGREGATE_OAUTH_APP_ID:-}" "AGGREGATE_OAUTH_APP_KEY:${AGGREGATE_OAUTH_APP_KEY:-}" "AGGREGATE_OAUTH_LOGIN_TYPE:${AGGREGATE_OAUTH_LOGIN_TYPE:-}" "AGGREGATE_OAUTH_ENDPOINT:${AGGREGATE_OAUTH_ENDPOINT:-}"; do K="${pair%%:*}"; V="${pair#*:}"; if [ -n "${V}" ]; then printf '%s="%s"\n' "$K" "$V" >> "$GITHUB_ENV"; echo "[OAuth-secrets] $K=已注入 (len=${#V})"; else echo "[OAuth-secrets] $K=未配置(Repository Secrets 中缺失或为空)"; fi; done; echo "[OAuth-secrets] 写入完成;后续所有 step 都会自动把上述变量作为进程级 process.env.* 继承(包括 nuxt build)"
|
||
|
||
- name: 安装依赖(npm ci,含 Runner 本地磁盘缓存 + 双源重试 + npm 版本对齐)
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
# 关键:本地生成 package-lock.json 使用的 npm 版本为 11.6.2(Node 24),
|
||
# 而 Preflight 默认 Node 22 自带 npm 10.x,lockfileVersion/校验不匹配会导致
|
||
# npm ci 报错并输出 help(其中包含 --install-links 字样,与你看到的日志一致)。
|
||
# 所以在任何 ci 操作之前,先把 npm 升级到与本地生成 lockfile 一致的大版本。
|
||
echo "=== Pre-upgrade npm to 11.x ==="
|
||
npm -v || true
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
sudo npm install -g "npm@^11.6.2" 2>/dev/null || \
|
||
sudo npm install -g "npm@latest-11" 2>/dev/null || true
|
||
else
|
||
npm install -g "npm@^11.6.2" 2>/dev/null || \
|
||
npm install -g "npm@latest-11" 2>/dev/null || true
|
||
fi
|
||
hash -r 2>/dev/null || true
|
||
npm -v
|
||
|
||
LOCK_HASH="$(sha256sum package-lock.json | cut -d' ' -f1)"
|
||
CACHE_FILE="${VOICEHUB_NPM_CACHE_DIR}/pf-${LOCK_HASH}.tgz"
|
||
mkdir -p "${VOICEHUB_NPM_CACHE_DIR}"
|
||
if [ -f "${CACHE_FILE}" ]; then
|
||
echo "[npm cache] HIT: ${CACHE_FILE},直接恢复 node_modules,跳过 npm ci"
|
||
tar -xzf "${CACHE_FILE}"
|
||
echo "[npm cache] 恢复完成"
|
||
else
|
||
echo "[npm cache] MISS: ${CACHE_FILE},执行 npm ci 并写入缓存"
|
||
NPM_REGISTRY_PRIMARY="${NPM_REGISTRY_PRIMARY:-https://registry.npmmirror.com}"
|
||
NPM_REGISTRY_FALLBACK="${NPM_REGISTRY_FALLBACK:-https://mirrors.cloud.tencent.com/npm/}"
|
||
MAX_RETRY=3
|
||
DELAY_SEC=15
|
||
CI_OK=0
|
||
for ATTEMPT in $(seq 1 $MAX_RETRY); do
|
||
if [ $(( ATTEMPT % 2 )) -eq 1 ]; then
|
||
CUR_REGISTRY="${NPM_REGISTRY_PRIMARY}"
|
||
else
|
||
CUR_REGISTRY="${NPM_REGISTRY_FALLBACK}"
|
||
fi
|
||
echo "--- [npm ci attempt $ATTEMPT/$MAX_RETRY] registry=${CUR_REGISTRY} npm=$(npm -v) node=$(node -v) ---"
|
||
npm config set registry "${CUR_REGISTRY}" || true
|
||
npm config set fund false
|
||
npm config set audit false
|
||
npm config set prefer-offline true 2>/dev/null || true
|
||
npm config set cache-min 3600 2>/dev/null || true
|
||
npm config set fetch-retries 5 2>/dev/null || true
|
||
npm config set fetch-retry-maxtimeout 300000 2>/dev/null || true
|
||
npm config set fetch-timeout 300000 2>/dev/null || true
|
||
npm config set fetch-retry-mintimeout 20000 2>/dev/null || true
|
||
set +e
|
||
npm ci --legacy-peer-deps
|
||
CI_EXIT=$?
|
||
set -e
|
||
if [ ${CI_EXIT} -eq 0 ]; then
|
||
CI_OK=1
|
||
echo "[npm ci] attempt ${ATTEMPT} SUCCESS"
|
||
break
|
||
fi
|
||
echo "[npm ci] attempt ${ATTEMPT} FAILED(exit=${CI_EXIT})"
|
||
echo " last 40 lines of stderr / help(若出现 --install-links 字样,通常是 npm<11 vs lockfile 版本不匹配):"
|
||
echo " node=$(node -v) npm=$(npm -v) lockfileVersion=$(node -e "try{const j=require('./package-lock.json');console.log(j.lockfileVersion||'n/a')}catch(e){console.log('n/a')}" 2>/dev/null || echo n/a)"
|
||
if [ $ATTEMPT -lt $MAX_RETRY ]; then
|
||
echo " ${DELAY_SEC}s 后重试下一轮(若还有)..."
|
||
sleep $DELAY_SEC
|
||
fi
|
||
done
|
||
if [ "${CI_OK}" != "1" ]; then
|
||
# 兜底:npm ci 只要 lockfile 有任何不同步(比如本地生成 vs CI Node 版本导致的可选依赖差异、
|
||
# 典型如 @parcel/watcher 在 CI Node 24 上作为可选依赖被拉起但 lock 中缺记录)就会 EUSAGE。
|
||
# 此时退化为 npm install --legacy-peer-deps(生成与本机一致的 node_modules + 同步 lock),
|
||
# 并用新 lock 重新算 hash 写缓存,避免下次相同 tree 再走到这里。
|
||
echo "WARN: npm ci 全部轮次仍失败,尝试降级为 npm install --legacy-peer-deps 兜底(会同步 package-lock.json)"
|
||
set +e
|
||
npm install --legacy-peer-deps --no-audit --no-fund
|
||
FALLBACK_EXIT=$?
|
||
set -e
|
||
if [ ${FALLBACK_EXIT} -eq 0 ]; then
|
||
echo "WARN: fallback npm install SUCCESS(后续建议本地执行 npm install 并提交新的 package-lock.json 以消除这一步)"
|
||
FALLBACK_LOCK_HASH="$(sha256sum package-lock.json | cut -d' ' -f1)"
|
||
FALLBACK_CACHE_FILE="${VOICEHUB_NPM_CACHE_DIR}/pf-${FALLBACK_LOCK_HASH}.tgz"
|
||
if [ "${FALLBACK_CACHE_FILE}" != "${CACHE_FILE}" ] && [ ! -f "${FALLBACK_CACHE_FILE}" ]; then
|
||
tar -czf "${FALLBACK_CACHE_FILE}" node_modules
|
||
echo "[npm cache][fallback] 已写入缓存: ${FALLBACK_CACHE_FILE}"
|
||
fi
|
||
else
|
||
echo "ERROR: 降级 npm install --legacy-peer-deps 也失败(exit=${FALLBACK_EXIT}),请检查 Runner 网络是否能访问 ${NPM_REGISTRY_PRIMARY} / ${NPM_REGISTRY_FALLBACK}" >&2
|
||
echo " 可在 Variables 中覆盖 NPM_REGISTRY_PRIMARY / NPM_REGISTRY_FALLBACK,或在 Runner 宿主机手动配 npm 代理" >&2
|
||
echo " 快速自检(在 Runner 宿主机执行):node -v && npm -v && npm view vue version --registry=${NPM_REGISTRY_PRIMARY}" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
if [ ! -f "${CACHE_FILE}" ]; then
|
||
tar -czf "${CACHE_FILE}" node_modules
|
||
echo "[npm cache] 已写入缓存: ${CACHE_FILE}"
|
||
else
|
||
echo "[npm cache] 已存在缓存,跳过重复写入: ${CACHE_FILE}"
|
||
fi
|
||
fi
|
||
|
||
- name: 检查 package.json 无 pnpm 残留
|
||
run: |
|
||
set -euo pipefail
|
||
grep -qE '"packageManager"\s*:\s*"pnpm' package.json && { echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止"; exit 1; }; echo "package.json 未发现 pnpm 声明"
|
||
|
||
- name: 执行 nuxt build
|
||
env:
|
||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
|
||
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
|
||
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
|
||
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
|
||
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
|
||
run: |
|
||
set -euxo pipefail
|
||
echo "=== 构建前系统内存状态 ==="
|
||
free -h || true
|
||
sync && echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true
|
||
echo "=== 开始执行 nuxt build ==="
|
||
export NODE_OPTIONS="--max-old-space-size=10240 --no-warnings --expose-gc"
|
||
export NITRO_PRESET=node-server
|
||
export NITRO_MINIFY=false
|
||
export DISABLE_ROLLDOWN=1
|
||
export OAUTH_REDIRECT_URI="${OAUTH_REDIRECT_URI:-}"
|
||
export OAUTH_STATE_SECRET="${OAUTH_STATE_SECRET:-}"
|
||
export AGGREGATE_OAUTH_APP_ID="${AGGREGATE_OAUTH_APP_ID:-}"
|
||
export AGGREGATE_OAUTH_APP_KEY="${AGGREGATE_OAUTH_APP_KEY:-}"
|
||
export AGGREGATE_OAUTH_LOGIN_TYPE="${AGGREGATE_OAUTH_LOGIN_TYPE:-}"
|
||
export AGGREGATE_OAUTH_ENDPOINT="${AGGREGATE_OAUTH_ENDPOINT:-}"
|
||
if [ -n "${AGGREGATE_OAUTH_APP_ID}" ]; then
|
||
echo "[OAuth] build-time AGGREGATE_OAUTH_APP_ID 已配置(前端第三方登录按钮会显示), LOGIN_TYPE=${AGGREGATE_OAUTH_LOGIN_TYPE:-<unset>}"
|
||
else
|
||
echo "[OAuth] build-time AGGREGATE_OAUTH_APP_ID 未配置——前端第三方登录将显示占位提示,请先在 Repository Secrets 中配置 AGGREGATE_OAUTH_APP_ID"
|
||
fi
|
||
npm run build
|
||
echo "=== 构建完成,.output/ 生成结果 ==="
|
||
ls -la .output/ 2>/dev/null || true
|
||
du -sh .output 2>/dev/null || true
|
||
echo "=== 构建后内存状态 ==="
|
||
free -h || true
|
||
|
||
meta-warmup:
|
||
name: 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化)
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 15
|
||
outputs:
|
||
image_tag: ${{ steps.meta.outputs.tag }}
|
||
image_full: ${{ steps.meta.outputs.full }}
|
||
image_latest: ${{ steps.meta.outputs.latest }}
|
||
repo_owner: ${{ steps.meta.outputs.repo_owner }}
|
||
repo_name: ${{ steps.meta.outputs.repo_name }}
|
||
steps:
|
||
- name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
# 只在真的缺包时才跑 apt-get update(Runner 通常自带 git/curl/ca-certificates/tar/xz,99% 场景直接跳过)
|
||
NEED_APT_UPDATE=0
|
||
need_cmd() {
|
||
local cmd="$1"
|
||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||
NEED_APT_UPDATE=1
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
need_pkg() {
|
||
local pkg="$1"
|
||
if command -v dpkg >/dev/null 2>&1; then
|
||
if dpkg -s "${pkg}" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
NEED_APT_UPDATE=1
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
NEED_GIT=0; need_cmd git || NEED_GIT=1
|
||
NEED_CURL=0; need_cmd curl || NEED_CURL=1
|
||
NEED_CA=0; need_pkg ca-certificates || NEED_CA=1
|
||
NEED_TAR=0; need_cmd tar || NEED_TAR=1
|
||
NEED_XZ=0; (need_cmd xz || need_cmd unxz) || NEED_XZ=1
|
||
|
||
APT_MISSING=()
|
||
[ "${NEED_GIT}" = "1" ] && APT_MISSING+=("git")
|
||
[ "${NEED_CURL}" = "1" ] && APT_MISSING+=("curl")
|
||
[ "${NEED_CA}" = "1" ] && APT_MISSING+=("ca-certificates")
|
||
[ "${NEED_TAR}" = "1" ] && APT_MISSING+=("tar")
|
||
[ "${NEED_XZ}" = "1" ] && APT_MISSING+=("xz-utils")
|
||
|
||
if [ "${#APT_MISSING[@]}" -gt 0 ]; then
|
||
echo "[apt] 检测到缺包: ${APT_MISSING[*]},执行 apt-get update + install"
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
sudo apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true
|
||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || \
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
|
||
else
|
||
apt-get update -qq 2>/dev/null || true
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends "${APT_MISSING[@]}" 2>/dev/null || true
|
||
fi
|
||
else
|
||
echo "[apt] 所有基础命令/包已存在,跳过 apt-get update(节省 30-90s)"
|
||
fi
|
||
if command -v update-ca-certificates >/dev/null 2>&1; then
|
||
sudo update-ca-certificates 2>/dev/null || update-ca-certificates 2>/dev/null || true
|
||
fi
|
||
|
||
# 镜像源替换:只在命中官方 archive.ubuntu.com / security.ubuntu.com 时才 sed,避免破坏 Runner 已配好的公司/国内镜像
|
||
APT_SOURCES_HIT=0
|
||
if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then
|
||
if grep -qE 'archive\.ubuntu\.com|security\.ubuntu\.com' /etc/apt/sources.list 2>/dev/null; then
|
||
APT_SOURCES_HIT=1
|
||
sudo sed -i \
|
||
-e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
|
||
-e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
|
||
-e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
|
||
-e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
|
||
/etc/apt/sources.list
|
||
fi
|
||
fi
|
||
if command -v apt-get >/dev/null 2>&1 && ls /etc/apt/sources.list.d/*.sources 1>/dev/null 2>&1; then
|
||
for f in /etc/apt/sources.list.d/*.sources; do
|
||
if grep -qE 'archive\.ubuntu\.com|security\.ubuntu\.com' "${f}" 2>/dev/null; then
|
||
APT_SOURCES_HIT=1
|
||
sudo sed -i \
|
||
-e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
|
||
-e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
|
||
-e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
|
||
-e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
|
||
"${f}" 2>/dev/null || true
|
||
fi
|
||
done
|
||
fi
|
||
if [ "${APT_SOURCES_HIT}" = "1" ]; then
|
||
echo "[apt] 检测到官方 Ubuntu 源域名,已替换为清华镜像,下次缺包 install 会走国内源"
|
||
else
|
||
echo "[apt] 未在 sources(.list.d) 中发现官方 archive.ubuntu.com 域名,跳过镜像源替换(保留 Runner 原有配置)"
|
||
fi
|
||
|
||
sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
|
||
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
|
||
|
||
DOCKER_DAEMON_CONF="/etc/docker/daemon.json"
|
||
if command -v docker >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
|
||
sudo mkdir -p /etc/docker 2>/dev/null || true
|
||
OLD_MIRRORS_JSON="{}"
|
||
if [ -f "${DOCKER_DAEMON_CONF}" ]; then
|
||
OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
|
||
fi
|
||
PY_DAEMON_B64="aW1wb3J0IGpzb24sIHN5cwp0cnk6CiAgICBkYXRhID0ganNvbi5sb2FkcyhzeXMuYXJndlsxXSBvciAie30iKQpleGNlcHQgRXhjZXB0aW9uOgogICAgZGF0YSA9IHt9Cm1pcnJvcnNfZW52cyA9IFttIGZvciBtIGluIHN5cy5hcmd2WzI6NV0gaWYgbV0KZXhpc3RpbmcgPSBkYXRhLmdldCgicmVnaXN0cnktbWlycm9ycyIpIG9yIFtdCmlmIGlzaW5zdGFuY2UoZXhpc3RpbmcsIGxpc3QpOgogICAgY29tYmluZWQgPSBsaXN0KGRpY3QuZnJvbWtleXMoWyptaXJyb3JzX2VudnMsICpleGlzdGluZ10pKQplbHNlOgogICAgY29tYmluZWQgPSBtaXJyb3JzX2VudnMKaWYgY29tYmluZWQ6CiAgICBkYXRhWyJyZWdpc3RyeS1taXJyb3JzIl0gPSBjb21iaW5lZApkYXRhLnNldGRlZmF1bHQoImxvZy1kcml2ZXIiLCAianNvbi1maWxlIikKcHJpbnQoanNvbi5kdW1wcyhkYXRhLCBlbnN1cmVfYXNjaWk9RmFsc2UsIGluZGVudD0yKSk="
|
||
NEW_DAEMON_JSON="$(printf '%s' "$PY_DAEMON_B64" | base64 -d | python3 - "$OLD_MIRRORS_JSON" \
|
||
"${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}")"
|
||
echo "${NEW_DAEMON_JSON}" | sudo tee "${DOCKER_DAEMON_CONF}" >/dev/null
|
||
echo "[Docker] daemon.json 已注入 registry-mirrors:"
|
||
sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || true
|
||
sudo systemctl restart docker 2>/dev/null \
|
||
|| sudo service docker restart 2>/dev/null \
|
||
|| echo "[Docker] systemctl/service 重启失败,跳过(后续 buildx builder 内部仍有 registry mirrors)"
|
||
sleep 3 2>/dev/null || true
|
||
fi
|
||
|
||
echo "环境准备完成"
|
||
cat /etc/os-release 2>/dev/null | head -4 || true
|
||
docker --version
|
||
docker buildx version 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
|
||
|
||
- name: 检出代码
|
||
run: |
|
||
set -euxo pipefail
|
||
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
|
||
BRANCH="${GITHUB_REF_NAME:-main}"
|
||
echo "Clone from: ${REPO_URL} (${BRANCH})"
|
||
rm -rf * .[^.]* 2>/dev/null || true
|
||
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||
git rev-parse --short HEAD
|
||
docker version
|
||
docker buildx version || echo "buildx 未安装,将使用 docker build --push"
|
||
|
||
- name: 注入 OAuth secrets 到 GITHUB_ENV
|
||
env:
|
||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
|
||
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
|
||
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
|
||
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
|
||
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
|
||
run: |
|
||
set -euo pipefail
|
||
OAUTH_ENV_FILE="$GITHUB_ENV"
|
||
_append_env() {
|
||
local K="$1" V="$2"
|
||
if [ -n "${V}" ]; then
|
||
printf '%s="%s"\n' "$K" "$V" >> "$OAUTH_ENV_FILE"
|
||
echo "[OAuth-secrets] $K=已注入 (len=${#V})"
|
||
else
|
||
echo "[OAuth-secrets] $K=未配置(Repository Secrets 中缺失或为空)"
|
||
fi
|
||
}
|
||
_append_env OAUTH_REDIRECT_URI "${OAUTH_REDIRECT_URI:-}"
|
||
_append_env OAUTH_STATE_SECRET "${OAUTH_STATE_SECRET:-}"
|
||
_append_env AGGREGATE_OAUTH_APP_ID "${AGGREGATE_OAUTH_APP_ID:-}"
|
||
_append_env AGGREGATE_OAUTH_APP_KEY "${AGGREGATE_OAUTH_APP_KEY:-}"
|
||
_append_env AGGREGATE_OAUTH_LOGIN_TYPE "${AGGREGATE_OAUTH_LOGIN_TYPE:-}"
|
||
_append_env AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT:-}"
|
||
echo "[OAuth-secrets] build-image 作业写入完成;docker build 可作为 build-arg 透传到 nuxt build"
|
||
|
||
- name: 计算镜像标签
|
||
id: meta
|
||
run: |
|
||
set -euo pipefail
|
||
REPO_OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||
REPO_NAME="${IMAGE_NAME,,}"
|
||
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||
SHORT_SHA="${SHA:0:7}"
|
||
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
|
||
IMG_FULL="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${DATE_TAG}"
|
||
IMG_LATEST="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:latest"
|
||
CACHE_REF_INNER="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:buildcache"
|
||
echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
|
||
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
|
||
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
|
||
echo "cache_ref=${CACHE_REF_INNER}" >> "$GITHUB_OUTPUT"
|
||
echo "repo_owner=${REPO_OWNER}" >> "$GITHUB_OUTPUT"
|
||
echo "repo_name=${REPO_NAME}" >> "$GITHUB_OUTPUT"
|
||
echo "======================================"
|
||
echo "Image tag : ${DATE_TAG}"
|
||
echo "Full image : ${IMG_FULL}"
|
||
echo "Latest image : ${IMG_LATEST}"
|
||
echo "Cache ref : ${CACHE_REF_INNER}"
|
||
echo "======================================"
|
||
|
||
- name: 初始化 Docker Buildx builder
|
||
env:
|
||
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
||
run: |
|
||
set -euxo pipefail
|
||
if [ -z "${REGISTRY_TOKEN}" ]; then
|
||
echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(个人应用令牌,权限 read:package + write:package + read:repository)"
|
||
exit 1
|
||
fi
|
||
REGISTRY_USER="${REGISTRY_USER,,}"
|
||
BUILDER_NAME="multi"
|
||
BUILDKIT_IMAGE="moby/buildkit:buildx-stable-1"
|
||
BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
|
||
{
|
||
printf '[worker.oci]\n max-parallelism = 2\n\n'
|
||
printf '[registry."docker.io"]\n mirrors = ["docker.m.daocloud.io","dockerproxy.com","hub-mirror.c.163.com","docker.nju.edu.cn","docker.mirrors.ustc.edu.cn"]\n\n'
|
||
printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
|
||
printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n\n'
|
||
printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
|
||
} > "${BKK_CFG}"
|
||
PULLED=0
|
||
for MIRROR in \
|
||
"docker.m.daocloud.io/${BUILDKIT_IMAGE}" \
|
||
"dockerproxy.com/${BUILDKIT_IMAGE}" \
|
||
"hub-mirror.c.163.com/${BUILDKIT_IMAGE}" \
|
||
"docker.nju.edu.cn/${BUILDKIT_IMAGE}" \
|
||
"docker.mirrors.ustc.edu.cn/${BUILDKIT_IMAGE}" \
|
||
"${BUILDKIT_IMAGE}"; do
|
||
if docker pull "${MIRROR}" 2>/dev/null; then
|
||
if [ "${MIRROR}" != "${BUILDKIT_IMAGE}" ]; then
|
||
docker tag "${MIRROR}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
|
||
fi
|
||
PULLED=1
|
||
break
|
||
fi
|
||
done
|
||
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像,请检查 Docker registry-mirrors"; exit 1; }
|
||
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
|
||
-u "${REGISTRY_USER}" --password-stdin
|
||
echo "宿主 Docker 登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
|
||
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
|
||
docker buildx create \
|
||
--name "${BUILDER_NAME}" \
|
||
--driver docker-container \
|
||
--use \
|
||
--config "${BKK_CFG}" \
|
||
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
|
||
--driver-opt network=host \
|
||
--driver-opt "image=${BUILDKIT_IMAGE}"
|
||
rm -f "${BKK_CFG}"
|
||
GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
|
||
GIT_CONFIG_VALUE_0="https://github.com/"
|
||
GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf"
|
||
GIT_CONFIG_VALUE_1="https://gist.github.com/"
|
||
export GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
|
||
docker buildx inspect --bootstrap
|
||
echo "Builder 启动成功,buildkitd.toml 已内置 ${GITEA_HOST} Basic Auth 凭据,push 不再走 OAuth2 flow"
|
||
|
||
- name: 确认已登录
|
||
run: |
|
||
set -euo pipefail
|
||
docker info 2>/dev/null | grep -E "Username|Registry" || echo "已存在登录凭据(跳过详细输出)"
|
||
env:
|
||
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
||
|
||
build-image:
|
||
name: 构建并推送容器镜像
|
||
needs: [preflight, meta-warmup]
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 30
|
||
outputs:
|
||
image_tag: ${{ needs.meta-warmup.outputs.image_tag }}
|
||
image_full: ${{ needs.meta-warmup.outputs.image_full }}
|
||
image_latest: ${{ needs.meta-warmup.outputs.image_latest }}
|
||
steps:
|
||
- name: 检出代码
|
||
run: |
|
||
set -euxo pipefail
|
||
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
|
||
BRANCH="${GITHUB_REF_NAME:-main}"
|
||
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||
|
||
- name: 确保 Docker Buildx 插件可用(本作业容器内独立安装)
|
||
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
|
||
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} 都不通)"
|
||
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] 自检通过"
|
||
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
|
||
else
|
||
echo "[buildx] 插件在位但 create 仍不认识 --name,改用 docker build 兜底"
|
||
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||
fi
|
||
else
|
||
echo "[buildx] 安装未成功,改用 docker build 兜底"
|
||
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||
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 }}
|
||
OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }}
|
||
OCS_REVISION: ${{ github.sha }}
|
||
OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }}
|
||
run: |
|
||
set -euxo pipefail
|
||
OCS_SOURCE="${OCS_SOURCE_RAW,,}"
|
||
OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}"
|
||
IMG_FULL="${IMG_FULL,,}"
|
||
IMG_LATEST="${IMG_LATEST,,}"
|
||
|
||
BUILD_ARGS=(
|
||
--build-arg BUILDKIT_INLINE_CACHE=1
|
||
--build-arg NODE_ENV=production
|
||
--build-arg DOCKER_MIRROR="${DOCKER_MIRROR_PREFIX}"
|
||
--build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}"
|
||
--build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}"
|
||
)
|
||
if [ -n "${OAUTH_REDIRECT_URI:-}" ]; then BUILD_ARGS+=( --build-arg OAUTH_REDIRECT_URI="${OAUTH_REDIRECT_URI}" ); fi
|
||
if [ -n "${OAUTH_STATE_SECRET:-}" ]; then BUILD_ARGS+=( --build-arg OAUTH_STATE_SECRET="${OAUTH_STATE_SECRET}" ); fi
|
||
if [ -n "${AGGREGATE_OAUTH_APP_ID:-}" ]; then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_APP_ID="${AGGREGATE_OAUTH_APP_ID}" ); fi
|
||
if [ -n "${AGGREGATE_OAUTH_APP_KEY:-}" ];then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_APP_KEY="${AGGREGATE_OAUTH_APP_KEY}" ); fi
|
||
if [ -n "${AGGREGATE_OAUTH_LOGIN_TYPE:-}" ]; then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_LOGIN_TYPE="${AGGREGATE_OAUTH_LOGIN_TYPE}" ); fi
|
||
if [ -n "${AGGREGATE_OAUTH_ENDPOINT:-}" ]; then BUILD_ARGS+=( --build-arg AGGREGATE_OAUTH_ENDPOINT="${AGGREGATE_OAUTH_ENDPOINT}" ); fi
|
||
echo "[OAuth build-args] OAUTH_REDIRECT_URI=$( [ -n "${OAUTH_REDIRECT_URI:-}" ] && echo 已注入 || echo 空 )"
|
||
echo "[OAuth build-args] OAUTH_STATE_SECRET=$( [ -n "${OAUTH_STATE_SECRET:-}" ] && echo 已注入 || echo 空 )"
|
||
echo "[OAuth build-args] AGGREGATE_OAUTH_APP_ID=$( [ -n "${AGGREGATE_OAUTH_APP_ID:-}" ] && echo 已注入 || echo 空 )"
|
||
echo "[OAuth build-args] AGGREGATE_OAUTH_APP_KEY=$( [ -n "${AGGREGATE_OAUTH_APP_KEY:-}" ] && echo 已注入 || echo 空 )"
|
||
echo "[OAuth build-args] AGGREGATE_OAUTH_LOGIN_TYPE=$( [ -n "${AGGREGATE_OAUTH_LOGIN_TYPE:-}" ] && echo 已注入 || echo 空 )"
|
||
echo "[OAuth build-args] AGGREGATE_OAUTH_ENDPOINT=$( [ -n "${AGGREGATE_OAUTH_ENDPOINT:-}" ] && echo 已注入 || echo 空 )"
|
||
if [ "${SKIP_CACHE}" = "true" ]; then
|
||
BUILD_ARGS+=( --no-cache )
|
||
echo "已启用强制重新构建(inputs.skip_build_cache=true,跳过 BuildKit 分层缓存)"
|
||
else
|
||
echo "使用 BuildKit 分层缓存加速(默认不写 --no-cache)"
|
||
fi
|
||
|
||
# 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}" \
|
||
.
|
||
else
|
||
echo "[build] buildx 不可用,回退 docker build(生效的 mirror 由 daemon.json 决定)"
|
||
docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true
|
||
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}" \
|
||
.
|
||
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 push "${IMG_FULL}"
|
||
echo "开始推送:${IMG_LATEST}"
|
||
docker push "${IMG_LATEST}"
|
||
echo "推送完成:2 个 tag(date-sha + latest)"
|
||
|
||
- name: 镜像体积与层信息
|
||
env:
|
||
IMG_FULL: ${{ needs.meta-warmup.outputs.image_full }}
|
||
run: |
|
||
set -euxo pipefail
|
||
docker pull "${IMG_FULL}" || true
|
||
docker image inspect "${IMG_FULL}" --format='{{.Size}}' 2>/dev/null \
|
||
| awk '{printf "Image size: %.2f MB\n", $1/1024/1024}' || true
|
||
docker history --no-trunc "${IMG_FULL}" 2>/dev/null | head -20 || true
|
||
|
||
deploy:
|
||
name: 部署到生产服务器
|
||
needs: [preflight, build-image]
|
||
if: always() && needs.preflight.result == 'success' && needs.build-image.result == 'success' && (github.event_name == 'push' || inputs.deploy_target == true)
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 25
|
||
environment:
|
||
name: production
|
||
url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
|
||
steps:
|
||
- name: 校验部署所需密钥
|
||
env:
|
||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||
run: |
|
||
set -euo pipefail
|
||
MISSING=()
|
||
[ -n "${DEPLOY_HOST}" ] || MISSING+=("DEPLOY_HOST")
|
||
[ -n "${DEPLOY_USER}" ] || MISSING+=("DEPLOY_USER")
|
||
[ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
|
||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||
echo "缺少以下 Repository Secrets:${MISSING[*]}"
|
||
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)"
|
||
exit 1
|
||
fi
|
||
echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
|
||
|
||
- name: SSH 滚动部署
|
||
env:
|
||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || 22 }}
|
||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||
REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }}
|
||
REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }}
|
||
NUXT_PUBLIC_SITE_URL: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
|
||
IMAGE_NEW: ${{ needs.build-image.outputs.image_full }}
|
||
IMAGE_LATEST: ${{ needs.build-image.outputs.image_latest }}
|
||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
|
||
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
|
||
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
|
||
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
|
||
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
|
||
WECHAT_MINI_APPID: ${{ secrets.WECHAT_MINI_APPID }}
|
||
WECHAT_MINI_SECRET: ${{ secrets.WECHAT_MINI_SECRET }}
|
||
# 可选:微信订阅消息模板(JSON)。不设也没关系 —— 驳回通知的模板已内置在
|
||
# server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES 里;
|
||
# 只有"换模板 / 再加一种通知"时才需要在这里给一个 Secret。
|
||
# ⚠️ 少了这一行映射,下面 upsert_env_key 拿到的永远是空值(设了 Secret 也不生效)。
|
||
WECHAT_MINI_SUBSCRIBE_TEMPLATES: ${{ secrets.WECHAT_MINI_SUBSCRIBE_TEMPLATES }}
|
||
# 可选:酷狗凭证兜底(配一次永久用,不必进小程序粘贴 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: |
|
||
set -euo pipefail
|
||
IMAGE_NEW="${IMAGE_NEW,,}"
|
||
IMAGE_LATEST="${IMAGE_LATEST,,}"
|
||
GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
|
||
GITHUB_SERVER_URL="${GITHUB_SERVER_URL,,}"
|
||
GITEA_HOST="${GITEA_HOST,,}"
|
||
IMAGE_NAME="${IMAGE_NAME,,}"
|
||
|
||
SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
|
||
printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
|
||
chmod 600 "${SSH_KEY_FILE}"
|
||
|
||
SSH_OPTS=(
|
||
-i "${SSH_KEY_FILE}"
|
||
-o StrictHostKeyChecking=no
|
||
-o UserKnownHostsFile=/dev/null
|
||
-o ConnectTimeout=10
|
||
-o ServerAliveInterval=30
|
||
-o ServerAliveCountMax=8
|
||
-p "${DEPLOY_PORT}"
|
||
)
|
||
|
||
echo "=============================================="
|
||
echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
|
||
echo "Deploy image : ${IMAGE_NEW}"
|
||
echo "Deploy dir : ${DEPLOY_PATH}"
|
||
echo "=============================================="
|
||
|
||
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
|
||
|
||
ENV_SH="/tmp/deploy_env_$$"
|
||
ENV_PAYLOAD="$(printf 'export IMAGE_NEW=%q\nexport IMAGE_LATEST=%q\nexport DEPLOY_PATH=%q\nexport GITEA_HOST=%q\nexport GH_PROXY_PREFIX=%q\nexport REGISTRY_PULL_USER=%q\nexport REGISTRY_PULL_TOKEN=%q\nexport IMAGE_NAME=%q\nexport NUXT_PUBLIC_SITE_URL=%q\nexport GITHUB_SERVER_URL=%q\nexport GITHUB_REPOSITORY=%q\nexport GITHUB_REF_NAME=%q\nexport OAUTH_REDIRECT_URI=%q\nexport OAUTH_STATE_SECRET=%q\nexport AGGREGATE_OAUTH_APP_ID=%q\nexport AGGREGATE_OAUTH_APP_KEY=%q\nexport AGGREGATE_OAUTH_LOGIN_TYPE=%q\nexport AGGREGATE_OAUTH_ENDPOINT=%q\nexport WECHAT_MINI_APPID=%q\nexport WECHAT_MINI_SECRET=%q\nexport WECHAT_MINI_SUBSCRIBE_TEMPLATES=%q\nexport KUGOU_ADMIN_COOKIE=%q\nexport KUGOU_ADMIN_TOKEN=%q\n' \
|
||
"${IMAGE_NEW}" "${IMAGE_LATEST}" "${DEPLOY_PATH}" "${GITEA_HOST}" "${GH_PROXY_PREFIX}" \
|
||
"${REGISTRY_PULL_USER}" "${REGISTRY_PULL_TOKEN}" "${IMAGE_NAME}" "${NUXT_PUBLIC_SITE_URL}" \
|
||
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_REF_NAME}" \
|
||
"${OAUTH_REDIRECT_URI}" "${OAUTH_STATE_SECRET}" \
|
||
"${AGGREGATE_OAUTH_APP_ID}" "${AGGREGATE_OAUTH_APP_KEY}" \
|
||
"${AGGREGATE_OAUTH_LOGIN_TYPE}" "${AGGREGATE_OAUTH_ENDPOINT}" \
|
||
"${WECHAT_MINI_APPID}" "${WECHAT_MINI_SECRET}" \
|
||
"${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" \
|
||
"${KUGOU_ADMIN_COOKIE:-}" "${KUGOU_ADMIN_TOKEN:-}")"
|
||
printf '%s' "${ENV_PAYLOAD}" | ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}"
|
||
echo "[Deploy] env script written to ${ENV_SH} on remote"
|
||
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "set -e; ls -la ${ENV_SH}; grep -q IMAGE_NEW ${ENV_SH} && echo '[Deploy] env_sh IMAGE_NEW line OK'"
|
||
|
||
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- '${ENV_SH}'" <<'REMOTE_SCRIPT_MAIN'
|
||
set -euxo pipefail
|
||
ENV_SH="$1"
|
||
if [ -f "$ENV_SH" ]; then
|
||
. "$ENV_SH"
|
||
rm -f "$ENV_SH"
|
||
fi
|
||
|
||
DEPLOY_DIR="${DEPLOY_PATH}"
|
||
COMPOSE="docker compose"
|
||
|
||
echo "=============================================="
|
||
echo "Executor : $(whoami)"
|
||
echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
|
||
echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
|
||
echo "=============================================="
|
||
if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
|
||
echo "ERROR: IMAGE_NEW or DEPLOY_DIR is empty — env variables not propagated correctly."
|
||
echo "IMAGE_NEW=${IMAGE_NEW}"
|
||
echo "DEPLOY_DIR=${DEPLOY_DIR}"
|
||
exit 9
|
||
fi
|
||
|
||
mkdir -p "${DEPLOY_DIR}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
|
||
cd "${DEPLOY_DIR}"
|
||
|
||
TEST_FILE="${DEPLOY_DIR}/.deploy_writable_test_$$"
|
||
if ! (touch "${TEST_FILE}" && rm -f "${TEST_FILE}") 2>/dev/null; then
|
||
echo "================================================================="
|
||
echo "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
|
||
echo "当前用户:$(whoami)"
|
||
echo "目录归属与权限:"
|
||
ls -ld "${DEPLOY_DIR}" || true
|
||
echo ""
|
||
echo "请在服务器上以 root 身份执行下面 3 行命令修复(复制粘贴):"
|
||
echo " chown -R deploy:docker '${DEPLOY_DIR}'"
|
||
echo " chmod 775 '${DEPLOY_DIR}'"
|
||
echo " setfacl -R -m u:deploy:rwx -m g:docker:rwx -m d:u:deploy:rwx -m d:g:docker:rwx '${DEPLOY_DIR}' 2>/dev/null || true"
|
||
echo "修复后重新触发此 workflow 即可。"
|
||
echo "================================================================="
|
||
exit 3
|
||
fi
|
||
|
||
if [ -n "${AGGREGATE_OAUTH_APP_ID}" ] || [ -n "${WECHAT_MINI_APPID}" ]; then
|
||
ENV_FILE="${DEPLOY_DIR}/.env"
|
||
touch "${ENV_FILE}"
|
||
MERGE_OK=1
|
||
upsert_env_key() {
|
||
_K="$1"; _V="$2"
|
||
if [ -z "${_V}" ]; then return 0; fi
|
||
# 值的引号要自适应:JSON(含双引号)必须用**单引号**包,
|
||
# 否则 .env 会变成 KEY="{"a":"b"}" 这种坏行,docker compose 解析直接崩。
|
||
# 值里不可能同时出现两种引号(JSON 不含单引号),所以这样是安全的。
|
||
_Q='"'
|
||
case "${_V}" in
|
||
*'"'*) _Q="'" ;;
|
||
esac
|
||
_LINE="${_K}=${_Q}${_V}${_Q}"
|
||
if grep -q "^${_K}=" "${ENV_FILE}" 2>/dev/null; then
|
||
# 用 grep -v 重写而不是 awk 改字段:awk 的 FS="=" 会把值里 "=" 之后的内容丢掉
|
||
# (base64 密钥常带 "=" 填充,正好踩这个坑)
|
||
if grep -v "^${_K}=" "${ENV_FILE}" > "${ENV_FILE}.tmp.$$" 2>/dev/null; then
|
||
printf '%s\n' "${_LINE}" >> "${ENV_FILE}.tmp.$$"
|
||
mv "${ENV_FILE}.tmp.$$" "${ENV_FILE}" || { rm -f "${ENV_FILE}.tmp.$$"; MERGE_OK=0; }
|
||
else
|
||
rm -f "${ENV_FILE}.tmp.$$"
|
||
MERGE_OK=0
|
||
fi
|
||
else
|
||
printf '%s\n' "${_LINE}" >> "${ENV_FILE}"
|
||
fi
|
||
}
|
||
upsert_env_key OAUTH_REDIRECT_URI "${OAUTH_REDIRECT_URI}" || MERGE_OK=0
|
||
upsert_env_key OAUTH_STATE_SECRET "${OAUTH_STATE_SECRET}" || MERGE_OK=0
|
||
upsert_env_key AGGREGATE_OAUTH_APP_ID "${AGGREGATE_OAUTH_APP_ID}" || MERGE_OK=0
|
||
upsert_env_key AGGREGATE_OAUTH_APP_KEY "${AGGREGATE_OAUTH_APP_KEY}" || MERGE_OK=0
|
||
upsert_env_key AGGREGATE_OAUTH_LOGIN_TYPE "${AGGREGATE_OAUTH_LOGIN_TYPE}" || MERGE_OK=0
|
||
upsert_env_key AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT}" || MERGE_OK=0
|
||
upsert_env_key WECHAT_MINI_APPID "${WECHAT_MINI_APPID}" || MERGE_OK=0
|
||
upsert_env_key WECHAT_MINI_SECRET "${WECHAT_MINI_SECRET}" || MERGE_OK=0
|
||
# 微信订阅消息模板(JSON)。仓库 Secret 里没设这项时 upsert_env_key 直接返回,
|
||
# 不会清掉服务器 .env 里手工配置的值。
|
||
# ⚠️ 变量必须同时在【本 step 的 env:】【ENV_PAYLOAD 的 export 列表】两处出现:
|
||
# 远端脚本是 `set -euxo pipefail`,只在一处声明 → 另一端就是 unbound variable,
|
||
# 整个部署会在这里挂掉(第 90 轮踩过)。这里再补 `:-` 兜底。
|
||
upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" || MERGE_OK=0
|
||
# 酷狗凭证兜底(可选):配一次永久用;库里 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:+已配置})"
|
||
else
|
||
echo "[OAuth] WARNING:运行期配置合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署"
|
||
fi
|
||
else
|
||
echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .env。如已在服务器手动配置过 .env 则无需担心。"
|
||
fi
|
||
|
||
if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
|
||
echo "使用专用 pull 凭证登录 Gitea Container Registry"
|
||
printf '%s' "${REGISTRY_PULL_TOKEN}" \
|
||
| docker login "${GITEA_HOST}" \
|
||
-u "${REGISTRY_PULL_USER}" \
|
||
--password-stdin 2>/dev/null || true
|
||
fi
|
||
|
||
echo "Pulling new image ${IMAGE_NEW} ..."
|
||
docker pull "${IMAGE_NEW}"
|
||
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
|
||
echo "New image pulled and tagged as latest"
|
||
|
||
printf 'services:\n voicehub:\n image: %s\n' "${IMAGE_NEW}" > docker-compose.override.yml
|
||
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
|
||
echo "--- override.yml content ---"
|
||
cat docker-compose.override.yml
|
||
echo "--- override.yml end ---"
|
||
|
||
if [ ! -f docker-compose.yml ]; then
|
||
echo "docker-compose.yml 不存在,自动从仓库模板下载(如访问 GitHub 会自动加 gh-proxy 前缀)"
|
||
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
|
||
case "${TEMPLATE_URL}" in
|
||
*"://github.com/"*) TEMPLATE_URL="${GH_PROXY_PREFIX}${TEMPLATE_URL}" ;;
|
||
esac
|
||
echo "URL=${TEMPLATE_URL}"
|
||
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|
||
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env(参考仓库根目录 .env.example)"
|
||
fi
|
||
|
||
if [ ! -f .env ]; then
|
||
echo "================================================================="
|
||
echo "WARNING:${DEPLOY_DIR}/.env 不存在"
|
||
echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL + 5 个 cpus 数字兜底:APP_CPUS/APP_CPUS_LIMIT/APP_CPUS_RESERVATION/PG_CPUS/REDIS_CPUS)"
|
||
echo "参考仓库根目录的 .env.example"
|
||
echo "================================================================="
|
||
fi
|
||
|
||
echo "Executing docker compose up -d ..."
|
||
${COMPOSE} up -d --remove-orphans
|
||
|
||
MAX_WAIT=24
|
||
WAITED=0
|
||
echo "Waiting for container healthcheck (max 120s)..."
|
||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||
STATUS="$(${COMPOSE} ps --format json 2>/dev/null || true)"
|
||
if echo "${STATUS}" | grep -q '"Health":"healthy"'; then
|
||
echo "voicehub healthcheck passed"
|
||
break
|
||
fi
|
||
if echo "${STATUS}" | grep -q '"State":"running"'; then
|
||
:
|
||
else
|
||
echo "voicehub not running, current status:"
|
||
${COMPOSE} ps || true
|
||
fi
|
||
sleep 5
|
||
WAITED=$((WAITED + 1))
|
||
done
|
||
if [ ${WAITED} -ge ${MAX_WAIT} ]; then
|
||
echo "Healthcheck timeout. Last 120 lines of voicehub logs:"
|
||
${COMPOSE} logs --tail=120 voicehub || true
|
||
exit 1
|
||
fi
|
||
|
||
echo "Pruning images older than 72 hours..."
|
||
docker image prune -af --filter "until=72h" || true
|
||
docker builder prune -af || true
|
||
|
||
echo ""
|
||
echo "================================================================="
|
||
echo "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
|
||
echo "Rollback instructions (on server):"
|
||
echo " cd ${DEPLOY_DIR}"
|
||
echo " docker images | grep ${IMAGE_NAME} | head -5"
|
||
echo " printf 'services:\\n voicehub:\\n image: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
|
||
echo " docker compose up -d"
|
||
echo "================================================================="
|
||
REMOTE_SCRIPT_MAIN
|
||
|
||
rm -f "${SSH_KEY_FILE}"
|