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: "" jobs: preflight: name: Preflight 语法与构建检查 runs-on: ubuntu-latest timeout-minutes: 15 container: image: node:22-bookworm-slim steps: - name: 安装基础运行依赖(git/ca-certificates,已换清华 TUNA 源) run: | set -euxo pipefail # ========== Step 0:先用 HTTP 清华源装 ca-certificates(解鸡生蛋:HTTPS 需要证书,证书得先用 HTTP 装) ========== SOURCES_FILE="/etc/apt/sources.list.d/debian.sources" if [ -f "${SOURCES_FILE}" ]; then sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}" sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}" sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}" sed -i "s|https://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}" else if [ -f /etc/apt/sources.list ]; then sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list sed -i "s|http://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list sed -i "s|https://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list fi fi apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates update-ca-certificates 2>/dev/null || true # ========== Step 1:现在有了证书,换成 HTTPS 清华源(更安全)+ 装 git/curl ========== if [ -f "${SOURCES_FILE}" ]; then sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}" sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}" else if [ -f /etc/apt/sources.list ]; then sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list fi fi apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git curl rm -rf /var/lib/apt/lists/* # 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy 前缀 git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || 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: 安装依赖(npm ci) run: | set -euxo pipefail npm config set registry https://registry.npmmirror.com || true npm config set fund false npm config set audit false npm ci --legacy-peer-deps - name: 检查 package.json 无 pnpm 残留 run: | set -euo pipefail if grep -qE '"packageManager"\s*:\s*"pnpm' package.json; then echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止" exit 1 fi echo "package.json 未发现 pnpm 声明" - name: 执行 nuxt build run: | set -euxo pipefail NODE_OPTIONS="--max-old-space-size=2048" npm run build echo ".output/ 生成结果:" ls -la .output/ 2>/dev/null || true du -sh .output 2>/dev/null || true build-image: name: 构建并推送容器镜像 needs: preflight runs-on: ubuntu-latest timeout-minutes: 40 outputs: image_tag: ${{ steps.meta.outputs.tag }} image_full: ${{ steps.meta.outputs.full }} image_latest: ${{ steps.meta.outputs.latest }} steps: - name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy) run: | set -euxo pipefail # ========== Step 0:先装 ca-certificates(默认 HTTP archive.ubuntu.com 不需要证书,解鸡生蛋) ========== if command -v apt-get >/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 ca-certificates 2>/dev/null || \ DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || true sudo update-ca-certificates 2>/dev/null || update-ca-certificates 2>/dev/null || true fi # ========== Step 1:有了证书后换 HTTPS 清华源 ========== # Ubuntu 22.04 默认是 http://archive.ubuntu.com/ubuntu 和 http://security.ubuntu.com/ubuntu if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then 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 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 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 done fi # 全局 git:所有 https://github.com/ 自动走 gh-proxy 前缀 sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true # npm:强制用 npmmirror(与 preflight 对齐) npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true echo "环境准备完成" cat /etc/os-release 2>/dev/null | head -4 || true docker --version docker buildx version || echo "buildx 未安装" - 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: 计算镜像标签 id: meta run: | set -euo pipefail SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}" SHORT_SHA="${SHA:0:7}" DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}" IMG_FULL="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:${DATE_TAG}" IMG_LATEST="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:latest" echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT" echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT" echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT" echo "======================================" echo "Image tag : ${DATE_TAG}" echo "Full image : ${IMG_FULL}" echo "Latest image : ${IMG_LATEST}" echo "======================================" - name: 初始化 Docker Buildx builder(带国内 registry-mirrors + gh-proxy 环境) run: | set -euxo pipefail BUILDER_NAME="multi" BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)" # 用 printf 逐行写 buildkitd 配置,避免 YAML parser 把 heredoc 内部的 [...] 误判为 YAML key { 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' } > "${BKK_CFG}" 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 "env=HTTP_PROXY=${HTTP_PROXY_EXAMPLE}" \ --driver-opt "env=HTTPS_PROXY=${HTTPS_PROXY_EXAMPLE}" rm -f "${BKK_CFG}" export GIT_CONFIG_COUNT=2 export GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf" export GIT_CONFIG_VALUE_0="https://github.com/" export GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf" export GIT_CONFIG_VALUE_1="https://gist.github.com/" docker buildx inspect --bootstrap - name: 登录 Gitea 容器仓库 env: REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }} run: | set -euo pipefail if [ -z "${REGISTRY_TOKEN}" ]; then echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(权限:read:package + write:package)" exit 1 fi printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \ -u "${REGISTRY_USER}" --password-stdin echo "登录成功:${REGISTRY_USER} @ ${GITEA_HOST}" - name: 构建并推送镜像 env: CACHE_REF: ${{ env.GITEA_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache SKIP_CACHE: ${{ inputs.skip_build_cache }} IMG_FULL: ${{ steps.meta.outputs.full }} IMG_LATEST: ${{ steps.meta.outputs.latest }} OCS_SOURCE: ${{ github.server_url }}/${{ github.repository }} OCS_REVISION: ${{ github.sha }} OCS_CREATED: ${{ github.event.head_commit.timestamp }} run: | set -euxo pipefail BUILD_ARGS=( --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg NODE_ENV=production ) CACHE_ARGS=() if [ "${SKIP_CACHE}" != "true" ]; then CACHE_ARGS=( --cache-from "type=registry,ref=${CACHE_REF}" --cache-to "type=registry,ref=${CACHE_REF},mode=max" ) else echo "已启用 skip_build_cache,不使用分层缓存,强制重新构建" BUILD_ARGS+=( --no-cache ) fi docker buildx build \ --context . \ --file ./Dockerfile \ --platform linux/amd64 \ --push \ --provenance=false \ "${BUILD_ARGS[@]}" \ "${CACHE_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}" - name: 镜像体积与层信息 env: IMG_FULL: ${{ steps.meta.outputs.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: build-image if: ${{ !cancelled() && !failure() && (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 }} run: | set -euo pipefail # 写私钥到临时文件(避免 stdin 转义问题) 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 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上 ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version" # 把变量作为 heredoc 脚本的输入传过去,远端展开我们需要的值 ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \ IMAGE_NEW="${IMAGE_NEW}" \ IMAGE_LATEST="${IMAGE_LATEST}" \ DEPLOY_PATH="${DEPLOY_PATH}" \ GITEA_HOST="${GITEA_HOST}" \ GH_PROXY_PREFIX="${GH_PROXY_PREFIX}" \ REGISTRY_PULL_USER="${REGISTRY_PULL_USER}" \ REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}" \ IMAGE_NAME="${IMAGE_NAME}" \ NUXT_PUBLIC_SITE_URL="${NUXT_PUBLIC_SITE_URL}" \ GITHUB_SERVER_URL="${GITHUB_SERVER_URL}" \ GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ bash <<'REMOTE_SCRIPT' set -euxo pipefail DEPLOY_DIR="${DEPLOY_PATH}" COMPOSE="docker compose" echo "==============================================" echo "Executor : $(whoami)" echo "Deploy image : ${IMAGE_NEW}" echo "Deploy dir : ${DEPLOY_DIR}" echo "==============================================" mkdir -p "${DEPLOY_DIR}" cd "${DEPLOY_DIR}" 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" cat > docker-compose.override.yml <\\n' > docker-compose.override.yml" echo " docker compose up -d" echo "=================================================================" REMOTE_SCRIPT # 清理私钥临时文件 rm -f "${SSH_KEY_FILE}"