# 精简版部署流水线(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 </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: \\n' > docker-compose.override.yml && docker compose up -d" REMOTE_SCRIPT_MAIN rm -f "${SSH_KEY_FILE}"