- 删掉 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 写坏)。
531 lines
26 KiB
YAML
531 lines
26 KiB
YAML
# 精简版部署流水线(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
|
||
|
||
on:
|
||
push:
|
||
branches: [main, master]
|
||
workflow_dispatch:
|
||
inputs:
|
||
deploy_target:
|
||
description: "只构建不部署时不要勾选"
|
||
type: boolean
|
||
default: true
|
||
skip_build_cache:
|
||
description: "跳过构建缓存(强制重新构建)"
|
||
type: boolean
|
||
default: false
|
||
|
||
env:
|
||
GITEA_HOST: gitea.fse-media.group
|
||
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
|
||
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:
|
||
build-image:
|
||
name: 构建并推送镜像
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 40
|
||
outputs:
|
||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||
image_full: ${{ steps.meta.outputs.image_full }}
|
||
image_latest: ${{ steps.meta.outputs.image_latest }}
|
||
steps:
|
||
- name: 准备 Docker(registry-mirrors)
|
||
run: |
|
||
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
|
||
sudo tee /etc/docker/daemon.json >/dev/null <<JSON
|
||
{
|
||
"registry-mirrors": [
|
||
"${DOCKER_REGISTRY_MIRROR_1}",
|
||
"${DOCKER_REGISTRY_MIRROR_2}",
|
||
"${DOCKER_REGISTRY_MIRROR_3}"
|
||
]
|
||
}
|
||
JSON
|
||
sudo systemctl restart docker 2>/dev/null \
|
||
|| sudo service docker restart 2>/dev/null \
|
||
|| echo "[Docker] 重启失败,继续(下面用显式 mirror 前缀拉镜像兜底)"
|
||
sleep 3 || true
|
||
fi
|
||
docker --version
|
||
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}"
|
||
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||
|
||
- name: 注入 OAuth secrets(构建期 build-arg)
|
||
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=未配置"
|
||
fi
|
||
done
|
||
|
||
- 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="${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}"
|
||
|
||
- 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
|
||
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
|
||
|
||
# 先把 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 "${M}" 2>/dev/null; then
|
||
[ "${M}" = "${BUILDKIT_IMAGE}" ] || docker tag "${M}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
|
||
break
|
||
fi
|
||
done
|
||
|
||
printf '%s' "${LOGIN_TOKEN}" | docker login "${GITEA_HOST}" -u "${LOGIN_USER}" --password-stdin
|
||
|
||
# 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)"
|
||
else
|
||
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 \
|
||
&& 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_USABLE=no" >> "$GITHUB_ENV"
|
||
echo "[buildx] builder 创建失败,转 docker build 兜底"
|
||
fi
|
||
else
|
||
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||
echo "[buildx] 插件不可用,转 docker build 兜底(无跨 run 缓存)"
|
||
fi
|
||
|
||
- name: 构建并推送镜像
|
||
env:
|
||
SKIP_CACHE: ${{ inputs.skip_build_cache }}
|
||
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 }}
|
||
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,,}"
|
||
CACHE_REF="${CACHE_REF,,}"
|
||
|
||
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}"
|
||
)
|
||
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
|
||
|
||
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] docker build 兜底(无构建缓存)"
|
||
docker build \
|
||
--file ./Dockerfile \
|
||
--platform linux/amd64 \
|
||
"${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" .
|
||
fi
|
||
|
||
docker image inspect "${IMG_FULL}" >/dev/null
|
||
docker image inspect "${IMG_LATEST}" >/dev/null
|
||
docker image inspect "${IMG_FULL}" --format='{{.Size}}' \
|
||
| awk '{printf "镜像体积:%.2f MB\n", $1/1024/1024}'
|
||
docker push "${IMG_FULL}"
|
||
docker push "${IMG_LATEST}"
|
||
echo "推送完成:${IMG_FULL} + ${IMG_LATEST}"
|
||
|
||
deploy:
|
||
name: 部署到生产服务器
|
||
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:
|
||
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 仓库 -> 设置 -> 工作流 -> 密钥 页面添加"
|
||
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 }}
|
||
WECHAT_MINI_SUBSCRIBE_TEMPLATES: ${{ secrets.WECHAT_MINI_SUBSCRIBE_TEMPLATES }}
|
||
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 "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
|
||
echo "Deploy image : ${IMAGE_NEW}"
|
||
echo "Deploy dir : ${DEPLOY_PATH}"
|
||
|
||
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 "Executor : $(whoami)"
|
||
echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
|
||
echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
|
||
if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
|
||
echo "ERROR: IMAGE_NEW or DEPLOY_DIR is empty — env variables not propagated correctly."
|
||
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 "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
|
||
echo "当前用户:$(whoami)"
|
||
ls -ld "${DEPLOY_DIR}" || true
|
||
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 即可。"
|
||
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 会变成坏行。
|
||
_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
|
||
# 变量必须同时在【本 step 的 env:】【ENV_PAYLOAD 的 export 列表】两处出现:
|
||
# 远端脚本是 set -euxo pipefail,只在一处声明 → 另一端就是 unbound variable。
|
||
upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" || MERGE_OK=0
|
||
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}"
|
||
else
|
||
echo "[Runtime-env] WARNING:合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署"
|
||
fi
|
||
else
|
||
echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .env"
|
||
fi
|
||
|
||
if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
|
||
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}"
|
||
|
||
printf 'services:\n voicehub:\n image: %s\n' "${IMAGE_NEW}" > docker-compose.override.yml
|
||
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
|
||
|
||
if [ ! -f docker-compose.yml ]; then
|
||
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
|
||
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|
||
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env"
|
||
fi
|
||
|
||
if [ ! -f .env ]; then
|
||
echo "WARNING:${DEPLOY_DIR}/.env 不存在(首次部署请参考仓库根目录 .env.example 创建)"
|
||
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
|
||
echo "${STATUS}" | grep -q '"State":"running"' || ${COMPOSE} ps || true
|
||
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 "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
|
||
echo "Rollback: cd ${DEPLOY_DIR} && printf 'services:\\n voicehub:\\n image: <OLD_TAG>\\n' > docker-compose.override.yml && docker compose up -d"
|
||
REMOTE_SCRIPT_MAIN
|
||
|
||
rm -f "${SSH_KEY_FILE}"
|