From 631328ea7c9bacb844bf518f853dc77ca51bf9df Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Mon, 22 Jun 2026 09:34:27 +0800 Subject: [PATCH] fix(deploy): require prebuilt web artifact for production --- infra/docker/compose.yml | 2 +- infra/docker/deploy-production.sh | 6 ++++++ infra/docker/web-artifact.Dockerfile | 6 ++++++ scripts/package_release_artifact.sh | 14 ++++++++++++++ tests/unit/test_release_scripts.py | 21 +++++++++++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 infra/docker/web-artifact.Dockerfile create mode 100644 scripts/package_release_artifact.sh diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index 665c25e51..c5afb4b1b 100644 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -49,7 +49,7 @@ services: image: ${WEB_IMAGE:-xiaoxia-saas-web:dev} build: context: ../.. - dockerfile: infra/docker/web.Dockerfile + dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile} container_name: xiaoxia-web-${ENV:-staging} restart: unless-stopped ports: diff --git a/infra/docker/deploy-production.sh b/infra/docker/deploy-production.sh index 0f66470e8..15590a53f 100755 --- a/infra/docker/deploy-production.sh +++ b/infra/docker/deploy-production.sh @@ -30,6 +30,11 @@ cd "$ROOT_DIR" cp "$ENV_FILE" "$ROOT_DIR/.env" mkdir -p "$ROOT_DIR/apps/web/public" "$HOST_PREFIX/var/lib/xiaoxia-saas-production/generated" [ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf 'placeholder' > "$ROOT_DIR/apps/web/public/.keep" +if [ ! -f "$ROOT_DIR/apps/web/dist/index.html" ]; then + echo "Missing prebuilt web artifact: $ROOT_DIR/apps/web/dist/index.html" + echo "Production deploy must not build frontend assets on the server. Run npm run build before packaging the release artifact." + exit 1 +fi ensure_container_running xiaoxia-postgres-production ensure_container_running xiaoxia-redis-production @@ -38,6 +43,7 @@ cd "$COMPOSE_DIR" export DOCKER_BUILDKIT=0 export COMPOSE_DOCKER_CLI_BUILD=0 export COMPOSE_PROJECT_NAME=xiaoxia-production-app +export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile docker compose --env-file "$ENV_FILE" build --pull=false api docker compose --env-file "$ENV_FILE" build --pull=false worker diff --git a/infra/docker/web-artifact.Dockerfile b/infra/docker/web-artifact.Dockerfile new file mode 100644 index 000000000..3abb20b8e --- /dev/null +++ b/infra/docker/web-artifact.Dockerfile @@ -0,0 +1,6 @@ +FROM docker.m.daocloud.io/library/nginx:alpine AS runner +WORKDIR /usr/share/nginx/html +COPY apps/web/dist ./ +COPY infra/docker/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/scripts/package_release_artifact.sh b/scripts/package_release_artifact.sh new file mode 100644 index 000000000..ff10f32ff --- /dev/null +++ b/scripts/package_release_artifact.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +OUTPUT_PATH="${1:-$ROOT_DIR/release-artifact.tar}" + +cd "$ROOT_DIR/apps/web" +npm ci +npm run build + +cd "$ROOT_DIR" +tar --exclude=.git --exclude=apps/web/node_modules -cf "$OUTPUT_PATH" . + +echo "Release artifact written to $OUTPUT_PATH" diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py index e5b48bb2d..78ed3ca51 100644 --- a/tests/unit/test_release_scripts.py +++ b/tests/unit/test_release_scripts.py @@ -23,6 +23,9 @@ 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 "xiaoxia-postgres-production" in script assert "xiaoxia-redis-production" in script assert "xiaoxia-postgres\n" not in script @@ -71,9 +74,27 @@ def test_staging_deploy_supports_host_prefix_and_web_build(): 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 + + +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 "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():