Files
xiaoxia-saas/tests/unit/test_release_scripts.py
T
2026-06-23 22:39:18 +08:00

332 lines
16 KiB
Python

from pathlib import Path
def test_restore_postgres_plan_is_non_destructive():
script = Path("scripts/restore_postgres_plan.sh").read_text(encoding="utf-8")
executable_prefix = script.split("cat <<EOF", 1)[0]
assert "pg_restore" in script
assert "This script intentionally does not execute restore automatically." in script
assert "pg_restore" not in executable_prefix
def test_gitea_production_deploy_uses_production_ports():
workflow = Path(".gitea/workflows/deploy.yml").read_text(encoding="utf-8")
production_section = workflow.split("deploy-production:", 1)[1]
assert "GITHUB_TOKEN: ${{ github.token }}" in workflow
assert "WEB_PORT=3001" not in production_section
assert "http://127.0.0.1:8001/health" in production_section
assert "RELEASE_VERSION='${GITHUB_REF_NAME}' sh -s" in production_section
assert "http://127.0.0.1:8000/health" not in production_section
def test_deploy_production_uses_production_infra_and_project():
script = Path("infra/docker/deploy-production.sh").read_text(encoding="utf-8")
assert 'HOST_PREFIX="${HOST_PREFIX-/host}"' in script
assert "apps/web/dist/index.html" in script
assert "Production deploy must not build frontend assets on the server" in script
assert "WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile" in script
assert "WEB_NGINX_CONF=infra/docker/nginx-production.conf" in script
assert "Skipping production API/worker image builds" in script
assert "RELEASE_VERSION" in script
assert "RUNTIME_IMAGE_TAR" in script
assert "docker load -i \"$RUNTIME_IMAGE_TAR\"" in script
assert 'export APP_VERSION="$RELEASE_VERSION"' in script
assert 'export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"' in script
assert 'export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"' in script
assert "docker image inspect \"${API_IMAGE:-xiaoxia-saas-api:dev}\"" in script
assert "docker image inspect \"${WORKER_IMAGE:-xiaoxia-saas-worker:dev}\"" in script
assert "ALLOW_PRODUCTION_BUILDS=true" in script
assert "xiaoxia-postgres-production" in script
assert "xiaoxia-redis-production" in script
assert "xiaoxia-postgres\n" not in script
assert "xiaoxia-redis\n" not in script
assert "COMPOSE_PROJECT_NAME=xiaoxia-production-app" in script
assert "--env-file \"$ENV_FILE\"" in script
assert "python /app/scripts/validate_release_env.py --from-environ --strict-external" in script
assert "alembic upgrade head" in script
assert "docker compose --env-file \"$ENV_FILE\" up -d api worker" in script
assert "docker compose --env-file \"$ENV_FILE\" up -d --force-recreate web" in script
assert "nginx resolves the current API container IP" in script
assert "/etc/cron.d/xiaoxia-production-resource-check" in script
assert "EXPECTED_VERSION=$RELEASE_VERSION" in script
def test_production_workflow_preserves_previous_web_assets():
workflow = Path(".gitea/workflows/deploy.yml").read_text(encoding="utf-8")
production_section = workflow.split("deploy-production:", 1)[1]
assert "old_assets_dir=\"/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}\"" in production_section
assert "docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. \"$old_assets_dir\"/" in production_section
assert "cp -a /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/. \"$old_assets_dir\"/" in production_section
assert "if [ ! -e \"/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name\" ]; then" in production_section
def test_production_deploy_recreates_web_after_api_for_nginx_dns():
script = Path("infra/docker/deploy-production.sh").read_text(encoding="utf-8")
api_up = script.index('docker compose --env-file "$ENV_FILE" up -d api worker')
web_up = script.index('docker compose --env-file "$ENV_FILE" up -d --force-recreate web')
assert api_up < web_up
assert "up -d api worker web" not in script
def test_production_nginx_static_upstream_requires_web_recreate():
config = Path("infra/docker/nginx-production.conf").read_text(encoding="utf-8")
script = Path("infra/docker/deploy-production.sh").read_text(encoding="utf-8")
assert "proxy_pass http://xiaoxia-api-production:8000/api/;" in config
assert "client_max_body_size 800m;" in config
assert "proxy_read_timeout 300s;" in config
assert "proxy_request_buffering off;" in config
assert 'location = /index.html' in config
assert 'Cache-Control "no-store, no-cache, must-revalidate" always' in config
assert "--force-recreate web" in script
def test_production_infra_uses_separate_containers_and_ports():
compose = Path("infra/docker/infra-production.yml").read_text(encoding="utf-8")
assert "name: xiaoxia-production" in compose
assert "container_name: xiaoxia-postgres-production" in compose
assert "container_name: xiaoxia-redis-production" in compose
assert "${POSTGRES_PORT:-5433}:5432" in compose
assert "${REDIS_PORT:-6380}:6379" in compose
assert "postgres_data:" not in compose
assert "redis_data:" not in compose
def test_init_production_env_is_non_deploying_and_separated():
script = Path("scripts/init_production_env.sh").read_text(encoding="utf-8")
assert "APP_ENV=production" in script
assert "ENVIRONMENT=production" in script
assert "xiaoxia-postgres-production" in script
assert "xiaoxia-redis-production" in script
assert "GENERATED_FILES_HOST_DIR=$GENERATED_DIR" in script
assert "ENABLE_EMAIL_DELIVERY=false" in script
assert "docker compose" not in script
assert "docker run" not in script
def test_staging_deploy_supports_host_prefix_and_web_build():
script = Path("infra/docker/deploy-staging.sh").read_text(encoding="utf-8")
assert 'HOST_PREFIX="${HOST_PREFIX-/host}"' in script
assert 'ROOT_DIR="$HOST_PREFIX/var/lib/xiaoxia-saas-staging/repo"' in script
assert 'ENV_FILE="$HOST_PREFIX/var/lib/xiaoxia-saas-staging/.env"' in script
assert '"$HOST_PREFIX/var/lib/xiaoxia-saas-staging/generated"' in script
assert "docker compose build --pull=false web" in script
def test_deploy_scripts_build_web_image_explicitly():
staging_script = Path("infra/docker/deploy-staging.sh").read_text(encoding="utf-8")
production_script = Path("infra/docker/deploy-production.sh").read_text(encoding="utf-8")
compose = Path("infra/docker/compose.yml").read_text(encoding="utf-8")
assert "docker compose build --pull=false web" in staging_script
assert "docker compose --env-file \"$ENV_FILE\" build --pull=false web" in production_script
assert "dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile}" in compose
assert "NGINX_CONF: ${WEB_NGINX_CONF:-infra/docker/nginx.conf}" in compose
assert "APP_VERSION: ${APP_VERSION:-0.1.0}" in compose
assert "WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}" in compose
def test_production_deploy_prunes_old_unused_docker_artifacts():
script = Path("infra/docker/deploy-production.sh").read_text(encoding="utf-8")
assert "PRUNE_UNUSED_DOCKER_AFTER_DEPLOY" in script
assert "docker image prune -af --filter \"until=168h\"" in script
assert "docker builder prune -af --filter \"until=168h\"" in script
def test_worker_runtime_is_constrained_by_environment():
dockerfile = Path("infra/docker/worker.Dockerfile").read_text(encoding="utf-8")
assert "--concurrency=${WORKER_CONCURRENCY:-1}" in dockerfile
assert "--max-tasks-per-child=${WORKER_MAX_TASKS_PER_CHILD:-100}" in dockerfile
def test_production_nginx_proxies_to_production_api_container():
config = Path("infra/docker/nginx-production.conf").read_text(encoding="utf-8")
assert "proxy_pass http://xiaoxia-api-production:8000/api/;" in config
assert "proxy_pass http://api:8000/api/;" not in config
def test_web_artifact_dockerfile_does_not_build_frontend_on_server():
dockerfile = Path("infra/docker/web-artifact.Dockerfile").read_text(encoding="utf-8")
assert "COPY apps/web/dist ./" in dockerfile
assert "ARG NGINX_CONF=infra/docker/nginx.conf" in dockerfile
assert "COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf" in dockerfile
assert "npm" not in dockerfile
assert "node" not in dockerfile.lower()
def test_release_artifact_script_builds_web_before_packaging():
script = Path("scripts/package_release_artifact.sh").read_text(encoding="utf-8")
assert "npm ci" in script
assert "npm run build" in script
assert "--exclude=apps/web/node_modules" in script
def test_web_dockerfile_uses_reachable_base_image_mirror():
dockerfile = Path("infra/docker/web.Dockerfile").read_text(encoding="utf-8")
assert "FROM docker.m.daocloud.io/library/node:20 AS builder" in dockerfile
assert "npm ci" in dockerfile
assert "COPY apps/web/package.json apps/web/package-lock.json" in dockerfile
assert "FROM docker.m.daocloud.io/library/nginx:alpine AS runner" in dockerfile
assert "FROM node:20" not in dockerfile
assert "FROM nginx:alpine" not in dockerfile
def test_admin_routes_use_coming_soon_until_backend_exists():
router = Path("apps/web/src/router/index.tsx").read_text(encoding="utf-8")
sidebar = Path("apps/web/src/components/layout/Sidebar.tsx").read_text(encoding="utf-8")
assert "AdminComingSoon" in router
assert "@/pages/admin/Dashboard" not in router
assert "@/pages/admin/UserManagement" not in router
assert "@/pages/admin/Analytics" not in router
assert "@/pages/admin/SystemMonitor" not in router
assert "@/pages/admin/LogViewer" not in router
assert "Admin(暂未开放)" in sidebar
assert "disabled: true" in sidebar
def test_subscription_api_is_not_wired_to_ui_until_backend_exists():
source_files = list(Path("apps/web/src").rglob("*.ts")) + list(Path("apps/web/src").rglob("*.tsx"))
importers = []
for path in source_files:
if path.as_posix().endswith("apps/web/src/api/subscription.ts"):
continue
text = path.read_text(encoding="utf-8")
if "@/api/subscription" in text or "../api/subscription" in text:
importers.append(path.as_posix())
assert importers == []
def test_gitea_production_deploy_requires_runtime_builder_job():
workflow = Path(".gitea/workflows/deploy.yml").read_text(encoding="utf-8")
build_section = workflow.split("build-production-runtime-images:", 1)[1].split("deploy-production:", 1)[0]
production_section = workflow.split("deploy-production:", 1)[1]
assert "runs-on: runtime-builder" in build_section
assert "scripts/build_release_images.sh \"${GITHUB_REF_NAME}\"" in build_section
assert "GITHUB_TOKEN: ${{ github.token }}" in build_section
assert "docker.m.daocloud.io/library/node:20" in build_section
assert "npm ci && npm run build" in build_section
assert "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" in build_section
assert "--exclude=./dist" in build_section
assert "--exclude=dist " not in build_section
assert "PRODUCTION_SSH_HOST" in build_section
assert "PRODUCTION_SSH_USER" in build_section
assert "PRODUCTION_SSH_KEY" in build_section
assert "/root/.ssh/xiaoxia_runtime_builder" in build_section
assert "runtime-images-${GITHUB_REF_NAME}.tar" in build_section
assert "release-${GITHUB_REF_NAME}.tar.gz" in build_section
assert "needs: build-production-runtime-images" in production_section
assert "runs-on: runtime-builder" in production_section
assert "Deploy production over SSH" in production_section
assert "release_tar=\"/var/lib/xiaoxia-saas-production/release-${RELEASE_VERSION}.tar.gz\"" in production_section
assert "runtime-images-${RELEASE_VERSION}.tar" in production_section
assert "apps/web/dist/index.html" in production_section
assert "HOST_PREFIX= sh /var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh" in production_section
def test_build_host_runbook_requires_off_production_runtime_builds():
runbook = Path("docs/BUILD-HOST-RUNBOOK.md").read_text(encoding="utf-8")
assert "Production host currently runs Gitea" in runbook
assert "scripts/build_release_images.sh v0.1.5" in runbook
assert "runtime-images-v0.1.5.tar" in runbook
assert "The deploy must fail if the runtime image tar is missing" in runbook
assert "python scripts/smoke_public_upload_flow.py" in runbook
def test_runtime_builder_runner_runbook_matches_workflow():
runbook = Path("docs/GITEA-RUNTIME-BUILDER-RUNNER.md").read_text(encoding="utf-8")
assert "runs-on: runtime-builder" in runbook
assert "PRODUCTION_SSH_HOST" in runbook
assert "PRODUCTION_SSH_USER" in runbook
assert "PRODUCTION_SSH_KEY" in runbook
assert "/root/.ssh/xiaoxia_runtime_builder" in runbook
assert "Do not install this runner on the current production host" in runbook
assert "production deploy waits for the image job" in runbook
assert "release-<tag>.tar.gz" in runbook
assert "prebuilt Web dist" in runbook
def test_deployment_docs_forbid_production_runtime_builds():
docs = Path("docs/DEPLOYMENT.md").read_text(encoding="utf-8")
assert "runtime-images-<tag>.tar" in docs
assert "API/Worker" in docs
assert "不是构建机" in docs
assert "infra/docker/deploy-production.sh" in docs
def test_runtime_image_release_scripts_keep_builds_off_production():
build_script = Path("scripts/build_release_images.sh").read_text(encoding="utf-8")
deploy_script = Path("scripts/deploy_release_images_production.sh").read_text(encoding="utf-8")
assert "docker build --pull=false -f infra/docker/api.Dockerfile" in build_script
assert "docker build --pull=false -f infra/docker/worker.Dockerfile" in build_script
assert "docker save" in build_script
assert "Refusing to build runtime images on a host that is running production services." in build_script
assert "ALLOW_SHARED_PRODUCTION_BUILD_HOST=true" in build_script
assert "docker load -i" in deploy_script
assert "API_IMAGE=\"xiaoxia-saas-api:$VERSION\"" in deploy_script
assert "WORKER_IMAGE=\"xiaoxia-saas-worker:$VERSION\"" in deploy_script
assert "ALLOW_PRODUCTION_BUILDS=false" in deploy_script
assert "docker compose --env-file \"$ENV_FILE\" build --pull=false web" in deploy_script
assert "docker compose --env-file \"$ENV_FILE\" build --pull=false api" not in deploy_script
assert "docker compose --env-file \"$ENV_FILE\" build --pull=false worker" not in deploy_script
def test_production_release_checklist_matches_automatic_release_contract():
checklist = Path("docs/PRODUCTION-RELEASE-CHECKLIST.md").read_text(encoding="utf-8")
assert "runtime-builder" in checklist
assert "release-<tag>.tar.gz" in checklist
assert "apps/web/dist/index.html" in checklist
assert "HOST_PREFIX=" in checklist
assert "curl -fsS http://127.0.0.1:8001/health" in checklist
assert "xiaoxia-postgres-production" in checklist
assert "v0.1.6" in checklist
assert "v0.1.9" in checklist
assert "生产机补 build" in checklist
def test_release_automation_retrospective_records_failed_probe_tags():
retrospective = Path("docs/RELEASE-AUTOMATION-RETROSPECTIVE-2026-06-22.md").read_text(
encoding="utf-8"
)
assert "v0.1.9" in retrospective
assert "first verified end-to-end automatic production release" in retrospective
assert "v0.1.6" in retrospective
assert "v0.1.7" in retrospective
assert "v0.1.8" in retrospective
assert "Do not use `v0.1.6`, `v0.1.7`, or `v0.1.8` as rollback targets" in retrospective
assert "HOST_PREFIX=" in retrospective
assert "--exclude=./dist" in retrospective
assert "public_upload_flow=ok" in retrospective
def test_backup_postgres_writes_manifest_and_version():
script = Path("scripts/backup_postgres.sh").read_text(encoding="utf-8")
assert "pg_dump" in script
assert "alembic_version.txt" in script
assert "manifest.txt" in script
assert "test -s" not in script
assert 'if [ ! -s "$DUMP_PATH" ]' in script