From 17fad9ecc061903ef0249f5f595c4910493ca21b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 18:18:42 +0800 Subject: [PATCH] ci: add isolated domestic frontend deployment --- .gitea/workflows/domestic-test.yml | 138 ++++++++++++++++++ .../__tests__/domestic-test-workflow.test.mjs | 33 +++++ .../server/deploy-domestic-test-archive.sh | 110 ++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 .gitea/workflows/domestic-test.yml create mode 100644 scripts/contracts/__tests__/domestic-test-workflow.test.mjs create mode 100644 scripts/server/deploy-domestic-test-archive.sh diff --git a/.gitea/workflows/domestic-test.yml b/.gitea/workflows/domestic-test.yml new file mode 100644 index 00000000..90031236 --- /dev/null +++ b/.gitea/workflows/domestic-test.yml @@ -0,0 +1,138 @@ +name: Deploy frontend to domestic test + +on: + workflow_dispatch: + +jobs: + deploy: + name: Build and deploy isolated domestic test + runs-on: gitea-label + container: + image: cozsweet-act-runner-node24-docker:latest + env: + NEXT_TELEMETRY_DISABLED: "1" + SENTRY_UPLOAD_SOURCEMAPS: "0" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate domestic SSH configuration + env: + SSH_HOST: ${{ secrets.DOMESTIC_TEST_SSH_HOST }} + SSH_PORT: ${{ secrets.DOMESTIC_TEST_SSH_PORT }} + SSH_PRIVATE_KEY: ${{ secrets.DOMESTIC_TEST_SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.DOMESTIC_TEST_SSH_KNOWN_HOSTS }} + run: | + set -eu + if [ -z "$SSH_HOST" ] || [ -z "$SSH_PRIVATE_KEY" ] || [ -z "$SSH_KNOWN_HOSTS" ]; then + echo "Missing domestic test SSH secrets" >&2 + exit 1 + fi + mkdir -p "$HOME/.ssh" + chmod 700 "$HOME/.ssh" + printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/domestic_test_deploy_key" + printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" + chmod 600 "$HOME/.ssh/domestic_test_deploy_key" "$HOME/.ssh/known_hosts" + + - name: Prepare domestic test environment + env: + TEST_ENV_FILE_CONTENT: ${{ secrets.TEST_ENV_FILE }} + run: | + set -eu + if [ -z "$TEST_ENV_FILE_CONTENT" ]; then + echo "Missing existing secret: TEST_ENV_FILE" >&2 + exit 1 + fi + umask 077 + printf '%s\n' "$TEST_ENV_FILE_CONTENT" > .env.domestic-test + + replace_env() { + key="$1" + value="$2" + awk -v key="$key" -v value="$value" ' + BEGIN { replaced = 0 } + $0 ~ "^" key "=" { print key "=" value; replaced = 1; next } + { print } + END { if (!replaced) print key "=" value } + ' .env.domestic-test > .env.domestic-test.next + mv .env.domestic-test.next .env.domestic-test + } + + replace_env NEXT_PUBLIC_APP_ENV test + replace_env NEXT_PUBLIC_API_BASE_URL https://testapi.banlv-ai.com + replace_env NEXT_PUBLIC_WS_BASE_URL wss://testapi.banlv-ai.com/ws + replace_env NEXTAUTH_URL http://127.0.0.1:9135 + replace_env NEXTAUTH_URL_INTERNAL http://127.0.0.1:3000 + + grep -Eq '^AUTH_SECRET=.+$' .env.domestic-test + grep -Eq '^NEXT_PUBLIC_API_BASE_URL=https://testapi\.banlv-ai\.com/?$' .env.domestic-test + grep -Eq '^NEXT_PUBLIC_WS_BASE_URL=wss://testapi\.banlv-ai\.com/ws/?$' .env.domestic-test + + - name: Prepare image metadata + run: | + set -eu + SHORT_SHA="$(git rev-parse --short HEAD)" + IMAGE="cozsweet-frontend-domestic-test:test-$SHORT_SHA" + ARCHIVE="cozsweet-frontend-domestic-test-$SHORT_SHA.tar.gz" + { + echo "SHORT_SHA=$SHORT_SHA" + echo "IMAGE=$IMAGE" + echo "ARCHIVE=$ARCHIVE" + } >> "$GITHUB_ENV" + + - name: Run frontend quality checks + run: | + set -eu + pnpm install --frozen-lockfile + pnpm lint + pnpm typecheck + pnpm test:contracts + + - name: Build and archive runtime image + run: | + set -eu + DOCKER_BUILDKIT=1 docker build \ + --secret id=next_env,src=.env.domestic-test \ + --build-arg NEXT_ENV_FILE=.env.domestic-test \ + -t "$IMAGE" \ + . + docker save "$IMAGE" | gzip -1 > "$ARCHIVE" + test -s "$ARCHIVE" + + - name: Deploy isolated domestic test container + env: + SSH_HOST: ${{ secrets.DOMESTIC_TEST_SSH_HOST }} + SSH_PORT: ${{ secrets.DOMESTIC_TEST_SSH_PORT }} + run: | + set -eu + DEPLOY_PORT="${SSH_PORT:-22}" + KEY_FILE="$HOME/.ssh/domestic_test_deploy_key" + SSH_TARGET="lzf@$SSH_HOST" + RUN_TOKEN="${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}" + DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test" + REMOTE_TMP="$DEPLOY_ROOT/tmp/actions-$RUN_TOKEN" + REMOTE_ARCHIVE="$REMOTE_TMP/$ARCHIVE" + REMOTE_ENV_FILE="$DEPLOY_ROOT/shared/test.env" + SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes" + SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes" + + cleanup_remote() { + ssh $SSH_OPTS "$SSH_TARGET" \ + "rm -f '$REMOTE_ARCHIVE' && rmdir '$REMOTE_TMP' 2>/dev/null || true" + } + trap cleanup_remote EXIT + + ssh $SSH_OPTS "$SSH_TARGET" \ + "mkdir -p '$DEPLOY_ROOT/current' '$DEPLOY_ROOT/releases/docker' '$DEPLOY_ROOT/shared' '$REMOTE_TMP' && chmod 700 '$DEPLOY_ROOT/shared'" + scp $SCP_OPTS scripts/server/deploy-domestic-test-archive.sh \ + "$SSH_TARGET:$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh" + scp $SCP_OPTS .env.domestic-test "$SSH_TARGET:$REMOTE_ENV_FILE" + scp $SCP_OPTS "$ARCHIVE" "$SSH_TARGET:$REMOTE_ARCHIVE" + ssh $SSH_OPTS "$SSH_TARGET" \ + "chmod 600 '$REMOTE_ENV_FILE' && chmod 700 '$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh' && '$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh' '$REMOTE_ARCHIVE' '$IMAGE' '$REMOTE_ENV_FILE' 9135" + + - name: Cleanup runner artifacts + if: always() + run: | + rm -f .env.domestic-test .env.domestic-test.next "${ARCHIVE:-}" "$HOME/.ssh/domestic_test_deploy_key" diff --git a/scripts/contracts/__tests__/domestic-test-workflow.test.mjs b/scripts/contracts/__tests__/domestic-test-workflow.test.mjs new file mode 100644 index 00000000..920f3cbf --- /dev/null +++ b/scripts/contracts/__tests__/domestic-test-workflow.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const workflowUrl = new URL( + "../../../.gitea/workflows/domestic-test.yml", + import.meta.url, +); +const deployScriptUrl = new URL( + "../../server/deploy-domestic-test-archive.sh", + import.meta.url, +); + +test("domestic frontend workflow is manual and registry-free", async () => { + const workflow = await readFile(workflowUrl, "utf8"); + + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /\bpush:/); + assert.doesNotMatch(workflow, /REGISTRY_IMAGE/); + assert.match(workflow, /docker save/); + assert.match(workflow, /DOMESTIC_TEST_SSH_PRIVATE_KEY/); +}); + +test("domestic frontend workflow pins the China test API and isolated port", async () => { + const workflow = await readFile(workflowUrl, "utf8"); + const deployScript = await readFile(deployScriptUrl, "utf8"); + + assert.match(workflow, /https:\/\/testapi\.banlv-ai\.com/); + assert.match(workflow, /wss:\/\/testapi\.banlv-ai\.com\/ws/); + assert.match(workflow, /9135/); + assert.match(deployScript, /127\.0\.0\.1:\$HOST_PORT/); + assert.match(deployScript, /cozsweet-frontend-domestic-test/); +}); diff --git a/scripts/server/deploy-domestic-test-archive.sh b/scripts/server/deploy-domestic-test-archive.sh new file mode 100644 index 00000000..1d1fc6b3 --- /dev/null +++ b/scripts/server/deploy-domestic-test-archive.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ARCHIVE="${1:-}" +IMAGE="${2:-}" +ENV_FILE="${3:-}" +HOST_PORT="${4:-9135}" +CONTAINER_NAME="cozsweet-frontend-domestic-test" +INTERNAL_PORT="3000" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SHARED_ROOT="$SERVICE_ROOT/shared" +RELEASE_ROOT="$SERVICE_ROOT/releases/docker" + +if [ -z "$ARCHIVE" ] || [ -z "$IMAGE" ] || [ -z "$ENV_FILE" ]; then + echo "Usage: $0 [host-port]" >&2 + exit 64 +fi +if [ "$HOST_PORT" != "9135" ]; then + echo "Domestic frontend test must use isolated host port 9135" >&2 + exit 64 +fi +case "$ENV_FILE" in + "$SHARED_ROOT"/*) ;; + *) + echo "Environment file must be stored under $SHARED_ROOT" >&2 + exit 64 + ;; +esac +if [ ! -s "$ARCHIVE" ] || [ ! -s "$ENV_FILE" ]; then + echo "Image archive or environment file is missing" >&2 + exit 66 +fi + +mkdir -p "$RELEASE_ROOT" +chmod 700 "$SHARED_ROOT" +gzip -dc "$ARCHIVE" | docker load >/dev/null +docker image inspect "$IMAGE" >/dev/null + +old_image_id="" +old_image_ref="" +if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then + old_image_id="$(docker inspect -f '{{.Image}}' "$CONTAINER_NAME")" + old_image_ref="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")" +elif ss -ltnH "sport = :$HOST_PORT" 2>/dev/null | grep -q .; then + echo "Host port $HOST_PORT is already used by another service" >&2 + exit 69 +fi + +run_container() { + local image_ref="$1" + docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "127.0.0.1:$HOST_PORT:$INTERNAL_PORT" \ + --env-file "$ENV_FILE" \ + -e "PORT=$INTERNAL_PORT" \ + "$image_ref" >/dev/null +} + +wait_healthy() { + local deadline=$((SECONDS + 180)) + while [ "$SECONDS" -le "$deadline" ]; do + state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || true)" + status="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$HOST_PORT/" 2>/dev/null || true)" + if [[ "$status" =~ ^(200|204|301|302|303|307|308)$ ]] && { [ "$state" = "healthy" ] || [ "$state" = "running" ]; }; then + return 0 + fi + sleep 2 + done + return 1 +} + +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +deployment_ok="1" +if ! run_container "$IMAGE"; then + deployment_ok="0" +elif ! wait_healthy; then + deployment_ok="0" +fi + +if [ "$deployment_ok" != "1" ]; then + docker logs --tail 120 "$CONTAINER_NAME" >&2 || true + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [ -n "$old_image_id" ] && docker image inspect "$old_image_id" >/dev/null 2>&1; then + echo "New container failed health checks; restoring previous image" >&2 + run_container "$old_image_id" + wait_healthy || true + fi + exit 1 +fi + +created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +image_id="$(docker image inspect "$IMAGE" -f '{{.Id}}')" +record="{\"createdAt\":\"$created_at\",\"environment\":\"domestic-test\",\"image\":\"$IMAGE\",\"imageId\":\"$image_id\",\"container\":\"$CONTAINER_NAME\",\"hostPort\":$HOST_PORT}" +if [ -f "$RELEASE_ROOT/current.json" ]; then + cp "$RELEASE_ROOT/current.json" "$RELEASE_ROOT/previous.json" +fi +printf '%s\n' "$record" > "$RELEASE_ROOT/current.json" +printf '%s\n' "$record" >> "$RELEASE_ROOT/releases.jsonl" + +echo "Domestic frontend test is healthy" +echo "container=$CONTAINER_NAME" +echo "host_port=$HOST_PORT" +echo "image=$IMAGE" +if [ -n "$old_image_ref" ]; then + echo "previous_image=$old_image_ref" +fi