diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index d5355cf..386f982 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -34,19 +34,26 @@ jobs: container: image: node:22-bookworm-slim steps: - - name: 检出代码 - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: 运行时环境自检 - shell: bash + - name: 安装基础运行依赖(git/ca-certificates) run: | set -euxo pipefail + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends \ + git ca-certificates curl + rm -rf /var/lib/apt/lists/* + git --version node -v npm -v - which node npm - cat /etc/os-release 2>/dev/null | head -4 || true + + - 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: | @@ -84,13 +91,19 @@ jobs: image_latest: ${{ steps.meta.outputs.latest }} steps: - name: 检出代码 - uses: actions/checkout@v4 - with: - fetch-depth: 1 + 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 - shell: bash run: | set -euo pipefail SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}" @@ -107,54 +120,83 @@ jobs: echo "Latest image : ${IMG_LATEST}" echo "======================================" - - name: 启用 QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: linux/amd64 - - - name: 启用 Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - install: true - - - name: 登录 Gitea 容器仓库 - uses: docker/login-action@v3 - with: - registry: ${{ env.GITEA_HOST }} - username: ${{ secrets.REGISTRY_USER || github.actor }} - password: ${{ secrets.REGISTRY_TOKEN || github.token }} - - - name: 构建并推送镜像 - id: build_push - uses: docker/build-push-action@v6 - env: - CACHE_REF: ${{ env.GITEA_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - push: true - provenance: false - tags: | - ${{ steps.meta.outputs.full }} - ${{ steps.meta.outputs.latest }} - build-args: | - BUILDKIT_INLINE_CACHE=1 - NODE_ENV=production - cache-from: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0}', env.CACHE_REF) }} - cache-to: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0},mode=max', env.CACHE_REF) }} - labels: | - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} - - - name: 镜像体积与层信息 + - name: 初始化 Docker Buildx builder run: | set -euxo pipefail - docker pull "${{ steps.meta.outputs.full }}" || true - docker image inspect "${{ steps.meta.outputs.full }}" --format='{{.Size}}' 2>/dev/null \ + BUILDER_NAME="multi" + # 如果已存在同名 builder,直接用;否则创建(默认 driver docker-container,支持 --cache-to registry) + if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then + docker buildx create --name "${BUILDER_NAME}" --driver docker-container --use \ + --buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' + else + docker buildx use "${BUILDER_NAME}" + fi + 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 "${{ steps.meta.outputs.full }}" 2>/dev/null | head -20 || true + docker history --no-trunc "${IMG_FULL}" 2>/dev/null | head -20 || true deploy: name: 部署到生产服务器 @@ -167,121 +209,169 @@ jobs: url: ${{ secrets.NUXT_PUBLIC_SITE_URL }} steps: - name: 校验部署所需密钥 - shell: bash + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} run: | set -euo pipefail MISSING=() - [ -n "${{ secrets.DEPLOY_HOST }}" ] || MISSING+=("DEPLOY_HOST") - [ -n "${{ secrets.DEPLOY_USER }}" ] || MISSING+=("DEPLOY_USER") - [ -n "${{ secrets.DEPLOY_SSH_KEY }}" ] || MISSING+=("DEPLOY_SSH_KEY") + [ -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 仓库 -> 设置 -> 工作流 -> 密钥 页面添加" + echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)" exit 1 fi - echo "部署密钥配置完整" + echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})" - name: SSH 滚动部署 - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.DEPLOY_HOST }} - port: ${{ secrets.DEPLOY_PORT || 22 }} - username: ${{ secrets.DEPLOY_USER }} - key: ${{ secrets.DEPLOY_SSH_KEY }} - command_timeout: 30m - script_stop: true - script: | - set -euxo pipefail + 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 - IMAGE_NEW="${{ needs.build-image.outputs.image_full }}" - IMAGE_LATEST="${{ needs.build-image.outputs.image_latest }}" - DEPLOY_DIR="${DEPLOY_PATH}" - COMPOSE="docker compose" + # 写私钥到临时文件(避免 stdin 转义问题) + SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)" + printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}" + chmod 600 "${SSH_KEY_FILE}" - echo "==============================================" - echo "Deploy image : ${IMAGE_NEW}" - echo "Deploy dir : ${DEPLOY_DIR}" - echo "Executor : $(whoami)" - echo "==============================================" + 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}" + ) - mkdir -p "${DEPLOY_DIR}" - cd "${DEPLOY_DIR}" + echo "==============================================" + echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}" + echo "Deploy image : ${IMAGE_NEW}" + echo "Deploy dir : ${DEPLOY_PATH}" + echo "==============================================" - if [ -n "${{ secrets.REGISTRY_PULL_TOKEN }}" ] && [ -n "${{ secrets.REGISTRY_PULL_USER }}" ]; then - echo "使用专用 pull 凭证登录 Gitea Container Registry" - echo "${{ secrets.REGISTRY_PULL_TOKEN }}" \ - | docker login "${GITEA_HOST}" \ - -u "${{ secrets.REGISTRY_PULL_USER }}" \ - --password-stdin 2>/dev/null || true - fi + # 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上 + ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version" - echo "Pulling new image ${IMAGE_NEW} ..." - docker pull "${IMAGE_NEW}" - docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}" - echo "New image pulled and tagged as latest" + # 把变量作为 heredoc 脚本的输入传过去,远端展开我们需要的值 + ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \ + IMAGE_NEW="${IMAGE_NEW}" \ + IMAGE_LATEST="${IMAGE_LATEST}" \ + DEPLOY_PATH="${DEPLOY_PATH}" \ + GITEA_HOST="${GITEA_HOST}" \ + 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 - cat > docker-compose.override.yml </dev/null || true fi - if echo "${STATUS}" | grep -q '"State":"running"'; then - : - else - echo "voicehub not running, current status:" - ${COMPOSE} ps || true + + 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 <' > docker-compose.override.yml" - echo " docker compose up -d" - echo "=================================================================" + 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: \\n' > docker-compose.override.yml" + echo " docker compose up -d" + echo "=================================================================" + REMOTE_SCRIPT + + # 清理私钥临时文件 + rm -f "${SSH_KEY_FILE}"