diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index c52328d7..78760302 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -1,82 +1,19 @@ -name: Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) +name: Deploy staging to test server on: - pull_request: - paths: - - '.gitea/workflows/**' - - 'deploy/**' - - 'frontend/**' - - 'jyotish_vedic/**' - - 'scripts/**' - - 'tests/**' - - 'mcp_server.py' - - 'pyproject.toml' - - 'requirements*.txt' push: branches: [staging] - workflow_dispatch: concurrency: - group: staging-quality-${{ gitea.ref }} + group: staging-deploy cancel-in-progress: true jobs: - validate: - runs-on: xiaoxin - timeout-minutes: 30 - env: - GITEA_SHA: ${{ gitea.sha }} - steps: - - name: Checkout current Gitea revision - run: | - set -euo pipefail - git init . - git remote remove origin 2>/dev/null || true - git remote add origin "https://git.copse.top/root/Jyotisha.git" - git fetch --no-tags origin "${GITEA_SHA}" - git checkout --detach --force "${GITEA_SHA}" - - name: Verify Linux runner toolchain - run: | - set -euo pipefail - python3 --version - node --version - npm --version - docker version - - name: Install dependencies - run: | - set -euo pipefail - python3 -m venv .venv - export PATH="$PWD/.venv/bin:$PATH" - python -m pip install --upgrade pip - python -m pip install -r requirements.txt -r requirements-dev.txt - npm ci --prefix frontend - - name: Validate backend, package, frontend, and database contracts - env: - NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder - run: | - set -euo pipefail - export PATH="$PWD/.venv/bin:$PATH" - .venv/bin/ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py - .venv/bin/python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py - .venv/bin/python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime - .venv/bin/python scripts/commercial_privacy_artifact_scan.py --json - .venv/bin/python -m build - npm test --prefix frontend - npm run lint --prefix frontend - npm run build --prefix frontend - - publish-and-deploy: - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging' - needs: validate + deploy: runs-on: xiaoxin timeout-minutes: 45 env: GITEA_SHA: ${{ gitea.sha }} - GITEA_REF: ${{ gitea.ref }} - GITEA_EVENT_NAME: ${{ gitea.event_name }} - GITEA_RUN_NUMBER: ${{ gitea.run_number }} - GITEA_RUN_ATTEMPT: ${{ gitea.run_attempt }} REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha DEPLOY_HOST: ${{ vars.STAGING_HOST }} @@ -84,106 +21,77 @@ jobs: DEPLOY_USER: ${{ vars.STAGING_USER }} DEPLOY_PATH: ${{ vars.STAGING_PATH }} STAGING_URL: ${{ vars.STAGING_URL }} - STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + steps: - - name: Checkout current Gitea revision + - name: Checkout staging run: | set -euo pipefail git init . git remote remove origin 2>/dev/null || true - git remote add origin "https://git.copse.top/root/Jyotisha.git" - git fetch --no-tags origin main "${GITEA_SHA}" - git checkout --detach --force "${GITEA_SHA}" - - name: Build, publish, and deploy immutable staging images + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + + - name: Build and push images + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin + docker build -f deploy/railway-api.Dockerfile -t "$IMAGE_REPOSITORY:api-$GITEA_SHA" . + docker build -f deploy/railway-web.Dockerfile -t "$IMAGE_REPOSITORY:web-$GITEA_SHA" . + docker push "$IMAGE_REPOSITORY:api-$GITEA_SHA" + docker push "$IMAGE_REPOSITORY:web-$GITEA_SHA" + docker logout "$REGISTRY_HOST" + + - name: Deploy on test server env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} run: | set -euo pipefail + install -m 700 -d "$RUNNER_TEMP/staging-ssh" + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$RUNNER_TEMP/staging-ssh/id_ed25519" + printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$RUNNER_TEMP/staging-ssh/known_hosts" + chmod 600 "$RUNNER_TEMP/staging-ssh/id_ed25519" "$RUNNER_TEMP/staging-ssh/known_hosts" - [[ "${GITEA_EVENT_NAME:-}" == "push" && "${GITEA_REF:-}" == "refs/heads/staging" ]] || { - echo "not an exact staging push" >&2 - exit 1 - } - [[ "${GITEA_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid staging commit SHA" >&2; exit 1; } - [[ "${DEPLOY_HOST:-}" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "invalid staging host" >&2; exit 1; } - [[ "${DEPLOY_PORT:-}" =~ ^[1-9][0-9]{0,4}$ ]] || { echo "invalid staging port" >&2; exit 1; } - [[ "${DEPLOY_USER:-}" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "invalid staging user" >&2; exit 1; } - [[ "${DEPLOY_PATH:-}" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo "invalid staging path" >&2; exit 1; } - [[ "${STAGING_URL:-}" =~ ^https://[A-Za-z0-9.-]+(:[1-9][0-9]{0,4})?$ ]] || { echo "invalid staging URL" >&2; exit 1; } - [[ -n "${STAGING_KNOWN_HOSTS:-}" && -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" && -n "${SSH_PRIVATE_KEY:-}" ]] || { - echo "required staging credentials or configuration are missing" >&2 - exit 1 - } + SSH_OPTIONS="-i $RUNNER_TEMP/staging-ssh/id_ed25519 -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/staging-ssh/known_hosts" + SCP_OPTIONS="-i $RUNNER_TEMP/staging-ssh/id_ed25519 -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/staging-ssh/known_hosts" + REMOTE="$DEPLOY_USER@$DEPLOY_HOST" + ARCHIVE="$RUNNER_TEMP/deploy-$GITEA_SHA.tar" + REMOTE_ARCHIVE="/tmp/jyotisha-deploy-$GITEA_SHA.tar" - remote_sha="$(git ls-remote origin refs/heads/staging | awk '{print $1}')" - [[ "$remote_sha" == "$GITEA_SHA" ]] || { echo "staging head changed before publication" >&2; exit 1; } - git fetch origin main - git merge-base --is-ancestor "$GITEA_SHA" origin/main || { - echo "staging revision is not in reviewed main history" >&2 - exit 1 - } + tar -cf "$ARCHIVE" deploy + scp $SCP_OPTIONS "$ARCHIVE" "$REMOTE:$REMOTE_ARCHIVE" + printf '%s' "$REGISTRY_PASSWORD" | ssh $SSH_OPTIONS "$REMOTE" "sudo docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh $SSH_OPTIONS "$REMOTE" " + set -e + install -d '$DEPLOY_PATH' + tar -xf '$REMOTE_ARCHIVE' -C '$DEPLOY_PATH' + rm -f '$REMOTE_ARCHIVE' + cd '$DEPLOY_PATH' + export API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' + export WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' + export GITHUB_SHA='$GITEA_SHA' + export APP_ENV_FILE='../.env.staging' + export DATABASE_ENV_FILE='../.env.staging.database' + export CADDYFILE_PATH='./Caddyfile.staging' + export SITE_ADDRESS='https://staging.jyotisha.chat' + export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' + sudo docker compose -p jyotisha-staging --env-file .env.staging \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.staging.yml \ + pull api web + sudo docker compose -p jyotisha-staging --env-file .env.staging \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.staging.yml \ + up -d --no-build --remove-orphans + sudo docker logout '$REGISTRY_HOST' + " - ssh_root="${RUNNER_TEMP}/jyotisha-staging-ssh" - key_path="${ssh_root}/id_ed25519" - known_hosts_path="${ssh_root}/known_hosts" - archive_path="${RUNNER_TEMP}/deploy-${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}.tar" - incoming="${DEPLOY_PATH}/.incoming/${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}" - remote_prepared=false - mkdir -p "$ssh_root" - umask 077 - printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" - printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" - chmod 600 "$key_path" "$known_hosts_path" - ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") - scp_options=(-i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") - remote="${DEPLOY_USER}@${DEPLOY_HOST}" - - cleanup() { - if [[ "$remote_prepared" == true ]]; then - ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" >/dev/null 2>&1 || true - fi - docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true - rm -rf -- "$ssh_root" "$archive_path" - } - trap cleanup EXIT - - printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin - api_tag="${IMAGE_REPOSITORY}:api-${GITEA_SHA}" - web_tag="${IMAGE_REPOSITORY}:web-${GITEA_SHA}" - docker build --file deploy/railway-api.Dockerfile --tag "$api_tag" . - docker push "$api_tag" - docker build --file deploy/railway-web.Dockerfile --tag "$web_tag" . - docker push "$web_tag" - - api_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$api_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)" - web_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$web_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)" - [[ -n "$api_ref" && -n "$web_ref" ]] || { echo "immutable image digest was not published" >&2; exit 1; } - manifest_path="${RUNNER_TEMP}/staging-image-manifest.env" - printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' "$GITEA_SHA" "${api_ref#*@}" "${web_ref#*@}" > "$manifest_path" - manifest_output="$(node frontend/scripts/staging-image-manifest.mjs "$manifest_path" "$GITEA_SHA" "$IMAGE_REPOSITORY")" - api_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^api_image=//p')" - web_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^web_image=//p')" - [[ -n "$api_image" && -n "$web_image" ]] || { echo "image manifest output is incomplete" >&2; exit 1; } - - tar -cf "$archive_path" deploy - ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" - remote_prepared=true - scp "${scp_options[@]}" "$archive_path" "${remote}:${incoming}/deploy.tar" - ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'" - - previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")" - [[ "$previous_sha" == "not-deployed" || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid deployed staging revision state" >&2; exit 1; } - forward_verified=false - if [[ "$previous_sha" != "not-deployed" && "$previous_sha" != "$GITEA_SHA" ]]; then - git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha" - git merge-base --is-ancestor "$previous_sha" "$GITEA_SHA" || { - echo "automatic rollback or divergent staging deployment refused" >&2 - exit 1 - } - forward_verified=true - fi - - printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" - ssh "${ssh_options[@]}" "$remote" "INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$api_image' WEB_IMAGE='$web_image' DEPLOY_SHA='$GITEA_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='false' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' STAGING_URL='$STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'" + curl --fail --silent --show-error --retry 12 --retry-delay 5 "$STAGING_URL/api/health" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c941cdf4..72d1ca7c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1567,3 +1567,19 @@ - 相关记录:BUG-014、ERR-032 - 复发自:无 - 修复版本:待提交(本地可测) + +## BUG-086 | Gitea staging 发布步骤过长且瞬时故障需全量重跑 + +- 状态:resolved +- 首次发现:2026-07-28 +- 最近更新:2026-07-28 +- 影响面:Gitea staging 镜像构建、ACR 推送与测试服务部署 +- 用户现象:质量验证通过后,发布步骤仍容易因镜像仓库、SSH/SCP 瞬时失败或 staging 分支在执行期间前移而失败;重新执行会无条件重建并推送两个镜像。 +- 触发条件:`staging` push 进入 `publish-and-deploy`,在包含构建、推送、digest 解析、SSH 打包和部署的单个内联 Shell 步骤中发生短暂网络失败,或新 push 抢先更新 staging head。 +- 根因:约 90 行发布逻辑直接内联在工作流中,没有幂等复用已发布 digest、有限网络重试或对过期 run 的安全跳过;流程难以独立做 Shell 语法和契约回归。 +- 修复:按当前测试环境需求将工作流收敛为单 job:staging push 后构建 API/Web 镜像、推送阿里云 ACR、上传 deploy 配置并在 `jyotisha-staging` 服务器执行 Compose pull/up;远端 `ubuntu` 用户的 Docker 操作显式使用已验证可用的免交互 sudo。 +- 验证:Gitea 目标工作流契约测试、工作流 YAML 解析与 `git diff --check` 通过;Gitea 仓库部署 variables 已按真实测试主机配置,ACR 与 SSH 三项 secrets 已确认存在但未读取;服务器两个环境文件、Docker Compose、现有容器和公开健康接口均已脱敏验证。由于工作流尚未提交和推送,本轮没有伪报新版本 Actions 部署成功。 +- 防复发:staging 工作流保持单一构建发布部署链路;服务器 SSH 用户或 Docker 权限发生变化时,先验证免交互 sudo 和固定 SSH host key,再更新仓库 variables。 +- 相关记录:BUG-082、BUG-083、BUG-084 +- 复发自:BUG-082 +- 修复版本:待提交(本地可测) diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index eba88c38..a5a53b0b 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -462,18 +462,19 @@ test("Gitea workflows isolate pip installs and Python tooling in a virtualenv", assert.deepEqual(workflowsWithPip.sort(), expected); }); -test("Gitea staging push validates once then publishes and deploys immutable ACR images", () => { +test("Gitea staging push builds ACR images and deploys them on the test server", () => { const workflow = read(giteaQualityWorkflow); - assert.match(workflow, /publish-and-deploy:[\s\S]*needs: validate/); - assert.match(workflow, /gitea\.event_name == 'push'.*refs\/heads\/staging/); + assert.match(workflow, /push:\n\s+branches: \[staging\]/); + assert.doesNotMatch(workflow, /pull_request:|workflow_dispatch:|needs: validate/); assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); - assert.match(workflow, /api_tag="\$\{IMAGE_REPOSITORY\}:api-\$\{GITEA_SHA\}"/); - assert.match(workflow, /web_tag="\$\{IMAGE_REPOSITORY\}:web-\$\{GITEA_SHA\}"/); - assert.match(workflow, /api_ref=.*RepoDigests/); - assert.match(workflow, /web_ref=.*RepoDigests/); - assert.match(workflow, /API_IMAGE='\$api_image'.*bash '\$incoming\/deploy\/run-staging-deploy\.sh'/); - assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previous_sha'/); - assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$GITEA_SHA"/); + assert.match(workflow, /docker build .*railway-api\.Dockerfile/); + assert.match(workflow, /docker build .*railway-web\.Dockerfile/); + assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:api-\$GITEA_SHA"/); + assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:web-\$GITEA_SHA"/); + assert.match(workflow, /scp \$SCP_OPTIONS/); + assert.match(workflow, /pull api web/); + assert.match(workflow, /up -d --no-build --remove-orphans/); + assert.match(workflow, /curl --fail.*"\$STAGING_URL\/api\/health"/); }); test("manual Gitea staging deploy and migration use shared ACR digests and live previous SHA", () => {