Files
Henry_Du 5962df638d ci(deploy): 工作流从 4 个 job 精简为 2 个,并启用 BuildKit registry 构建缓存
- 删掉 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 写坏)。
2026-10-11 00:14:22 +08:00

531 lines
26 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 精简版部署流水线(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}"