diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index fbc83a070..b62a88e50 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1,1796 +1,1695 @@ name: CI/CD Pipeline - -on: +true: push: branches: - - main - - develop - - 'feature/**' - - 'bugfix/**' - - 'hotfix/**' - - 'release/**' + - main + - develop + - feature/** + - bugfix/** + - hotfix/** + - release/** tags: - - 'v*' + - v* pull_request: branches: - - main - - develop - + - main + - develop permissions: contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - jobs: validate: name: Validate Code Quality And Tests runs-on: host timeout-minutes: 10 - env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas - USE_IN_MEMORY_DB: "false" - + USE_IN_MEMORY_DB: 'false' steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time - - name: Verify CI environment - shell: sh - run: | - set -eu - python3 --version - python3 -m pip --version - echo "CI environment is ready" + echo "Job started at $(date)"' + - name: Verify CI environment + shell: sh + run: 'set -eu - - name: Install dependencies - shell: sh - run: | - set -eu - python3 -m pip install -q -r requirements-base.txt - python3 -m pip install -q -r requirements.txt - python3 -m pip install -q -r requirements-dev.txt - python3 -m black --version - python3 -m isort --version-number - python3 -m flake8 --version - bandit --version - pytest --version + python3 --version - - name: Secret detection (detect-secrets) - shell: sh - run: | - set -eu - echo "=== Installing detect-secrets ===" - python3 -m pip install -q detect-secrets - detect-secrets --version - echo "" - echo "=== Running secret scan ===" - detect-secrets scan \ - --all-files \ - --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \ - --exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \ - --exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \ - --disable-plugin Base64HighEntropyString \ - --disable-plugin HexHighEntropyString \ - --disable-plugin BasicAuthDetector \ - --disable-plugin KeywordDetector \ - --disable-plugin IPPublicDetector \ - 2>&1 | tee /tmp/secrets-scan.json + python3 -m pip --version - FOUND=$(python3 -c " - import json - try: - with open('/tmp/secrets-scan.json') as f: - data = json.load(f) - results = data.get('results', {}) - total = sum(len(v) for v in results.values()) - print(total) - except Exception: - print('error') - ") - echo "" - echo "Secrets detected: $FOUND" - if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then - echo "" - echo "=== Secret details ===" - python3 -c " - import json - with open('/tmp/secrets-scan.json') as f: - data = json.load(f) - for fpath, items in data.get('results', {}).items(): - for item in items: - line = item.get('line_number', '?') - stype = item.get('type', '?') - hashed = item.get('hashed_secret', '')[:16] - print(f' {fpath}:{line} [{stype}] {hashed}...') - " - echo "" - echo "ERROR: Potential secrets detected in code!" - echo "If these are false positives, add exclusions in the CI workflow." - exit 1 - fi - echo "Secret scan completed - no secrets detected" + echo "CI environment is ready" + ' + - name: Install dependencies + shell: sh + run: 'set -eu - - name: Run code quality checks - shell: sh - run: | - set -eu - python3 -m compileall -q alembic apps packages tests scripts - python3 -m black --check --fast alembic apps packages tests scripts - python3 -m isort --check-only alembic apps packages tests scripts - python3 -m flake8 apps packages tests --count --statistics + python3 -m pip install -q -r requirements-base.txt - - name: Run security scan (bandit) - shell: sh - run: | - set -eu - bandit -r apps packages -q -ll + python3 -m pip install -q -r requirements.txt - - name: Python dependency vulnerability scan (pip-audit) - shell: sh - run: | - set -eu - echo "=== Installing pip-audit ===" - python3 -m pip install -q pip-audit - pip-audit --version - echo "" - echo "=== Scanning Python dependencies ===" - EXIT_CODE=0 - for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do - if [ -f "$req_file" ]; then - echo "--- Scanning $req_file ---" - pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$? - echo "" - fi - done - echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)" - if [ "$EXIT_CODE" != "0" ]; then - echo "WARNING: Potential vulnerabilities found in dependencies." - fi - exit 0 + python3 -m pip install -q -r requirements-dev.txt - - name: Dead code detection (vulture) - shell: sh - run: | - set -eu - echo "=== Installing vulture ===" - python3 -m pip install -q vulture - vulture --version - echo "" - echo "=== Running vulture dead code scan ===" - EXIT_CODE=0 - vulture apps packages scripts \ - --exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \ - --min-confidence 80 \ - 2>&1 | head -60 || EXIT_CODE=$? - echo "" - echo "vulture scan completed (advisory mode - P2, for reference only)" - if [ "$EXIT_CODE" != "0" ]; then - echo "NOTE: Potential dead code found (may include false positives from framework code)." - fi - exit 0 + python3 -m black --version - - name: Validate release scripts syntax - shell: sh - run: | - set -eu - bash -n scripts/backup_postgres.sh - bash -n scripts/restore_postgres_plan.sh - bash -n scripts/init_production_env.sh + python3 -m isort --version-number - - name: Validate Alembic migrations - shell: sh - run: | - set -eu - python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql - test -s /tmp/alembic-upgrade.sql - grep -q "Running upgrade" /tmp/alembic-upgrade.sql - python3 scripts/check_schema_metadata.py + python3 -m flake8 --version - - name: Prepare git for migration diff - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - if command -v git >/dev/null 2>&1; then - git init -q - git config user.email "ci@localhost" - git config user.name "CI" - git add . - git commit -q -m "current" - REPO_URL="https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" - git remote add origin "$REPO_URL" - git fetch origin main --depth=1 -q 2>/dev/null || echo "WARN: cannot fetch main, will check all migrations" - else - echo "WARN: git not available, will check all migrations" - fi + bandit --version - - name: Check migration safety - shell: sh - run: | - set -eu - if git rev-parse origin/main >/dev/null 2>&1; then - python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main - else - python3 scripts/check_migration_safety.py --allow-medium-risk - fi + pytest --version + ' + - name: Secret detection (detect-secrets) + shell: sh + run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install\ + \ -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running\ + \ secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files\ + \ '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/'\ + \ \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\\ + .css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\\ + .lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString\ + \ \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector\ + \ \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector\ + \ \\\n 2>&1 | tee /tmp/secrets-scan.json\n\nFOUND=$(python3 -c \"\nimport\ + \ json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data\ + \ = json.load(f)\n results = data.get('results', {})\n total = sum(len(v)\ + \ for v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\ + \")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\"\ + \ ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret\ + \ details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json')\ + \ as f:\n data = json.load(f)\nfor fpath, items in data.get('results',\ + \ {}).items():\n for item in items:\n line = item.get('line_number',\ + \ '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret',\ + \ '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n\ + \ echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo\ + \ \"If these are false positives, add exclusions in the CI workflow.\"\n \ + \ exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n" + - name: Run code quality checks + shell: sh + run: 'set -eu + + python3 -m compileall -q alembic apps packages tests scripts + + python3 -m black --check --fast alembic apps packages tests scripts + + python3 -m isort --check-only alembic apps packages tests scripts + + python3 -m flake8 apps packages tests --count --statistics + + ' + - name: Run security scan (bandit) + shell: sh + run: 'set -eu + + bandit -r apps packages -q -ll + + ' + - name: Python dependency vulnerability scan (pip-audit) + shell: sh + run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install\ + \ -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python\ + \ dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt\ + \ requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"---\ + \ Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1\ + \ | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit\ + \ scan completed (advisory mode - warnings only, not blocking CI)\"\nif [\ + \ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities\ + \ found in dependencies.\"\nfi\nexit 0\n" + - name: Dead code detection (vulture) + shell: sh + run: "set -eu\necho \"=== Installing vulture ===\"\npython3 -m pip install -q\ + \ vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code\ + \ scan ===\"\nEXIT_CODE=0\nvulture apps packages scripts \\\n --exclude \"\ + tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\"\ + \ \\\n --min-confidence 80 \\\n 2>&1 | head -60 || EXIT_CODE=$?\necho \"\ + \"\necho \"vulture scan completed (advisory mode - P2, for reference only)\"\ + \nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"NOTE: Potential dead code\ + \ found (may include false positives from framework code).\"\nfi\nexit 0\n" + - name: Validate release scripts syntax + shell: sh + run: 'set -eu + + bash -n scripts/backup_postgres.sh + + bash -n scripts/restore_postgres_plan.sh + + bash -n scripts/init_production_env.sh + + ' + - name: Validate Alembic migrations + shell: sh + run: 'set -eu + + python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql + + test -s /tmp/alembic-upgrade.sql + + grep -q "Running upgrade" /tmp/alembic-upgrade.sql + + python3 scripts/check_schema_metadata.py + + ' + - name: Prepare git for migration diff + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\nif command -v git >/dev/null 2>&1; then\n git init -q\n git\ + \ config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git\ + \ add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\ + \n git remote add origin \"$REPO_URL\"\n git fetch origin main --depth=1\ + \ -q 2>/dev/null || echo \"WARN: cannot fetch main, will check all migrations\"\ + \nelse\n echo \"WARN: git not available, will check all migrations\"\nfi\n" + - name: Check migration safety + shell: sh + run: "set -eu\nif git rev-parse origin/main >/dev/null 2>&1; then\n python3\ + \ scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main\n\ + else\n python3 scripts/check_migration_safety.py --allow-medium-risk\nfi\n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' unit-tests: name: Unit Tests runs-on: host timeout-minutes: 8 - env: - USE_IN_MEMORY_DB: "true" + USE_IN_MEMORY_DB: 'true' OSS_ACCESS_KEY_ID: placeholder OSS_ACCESS_KEY_SECRET: placeholder OSS_BUCKET_NAME: xiaoxia-autocut OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time - - name: Install ffmpeg - shell: sh - run: | - set +e - if command -v ffmpeg > /dev/null 2>&1; then - echo "ffmpeg already installed: $(ffmpeg -version | head -1)" - exit 0 - fi - if command -v apt-get > /dev/null 2>&1; then - apt-get update -qq && apt-get install -y -qq ffmpeg - elif command -v yum > /dev/null 2>&1; then - yum install -y -q epel-release 2>/dev/null - yum install -y -q ffmpeg 2>/dev/null - if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then - dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null - dnf install -y -q ffmpeg 2>/dev/null - fi - elif command -v dnf > /dev/null 2>&1; then - dnf install -y -q ffmpeg 2>/dev/null - fi - if command -v ffmpeg > /dev/null 2>&1; then - echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)" - else - echo "Warning: ffmpeg installation failed or not available, some tests may be skipped" - fi + echo "Job started at $(date)"' + - name: Install ffmpeg + shell: sh + run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already\ + \ installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get\ + \ > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\n\ + elif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release\ + \ 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] &&\ + \ command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck\ + \ https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E\ + \ %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n\ + \ fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg\ + \ 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"\ + ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo\ + \ \"Warning: ffmpeg installation failed or not available, some tests may be\ + \ skipped\"\nfi\n" + - name: Install dependencies + shell: sh + run: 'set -eu - - name: Install dependencies - shell: sh - run: | - set -eu - python3 -m pip install -q -r requirements-base.txt - python3 -m pip install -q -r requirements.txt - python3 -m pip install -q -r requirements-dev.txt - pytest --version + python3 -m pip install -q -r requirements-base.txt - - name: Run unit tests with coverage - shell: sh - run: | - set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ - --source=apps/api/app,packages \ - --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ - --branch \ - -m pytest tests/unit -q - python3 -m coverage report --show-missing - python3 -m coverage xml -o coverage.xml - python3 -m coverage report --fail-under=60 > /dev/null + python3 -m pip install -q -r requirements.txt - - name: CI failure notification - if: failure() - shell: sh - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} - run: | - set +e - FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py + python3 -m pip install -q -r requirements-dev.txt + pytest --version + ' + - name: Run unit tests with coverage + shell: sh + run: "set -eu\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run \\\n\ + \ --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\"\ + \ \\\n --branch \\\n -m pytest tests/unit -q\npython3 -m coverage report\ + \ --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage\ + \ report --fail-under=60 > /dev/null\n" + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: 'set +e + + FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py + + ' + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' integration-tests: name: Integration Tests runs-on: host timeout-minutes: 20 if: always() needs: validate - env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas - USE_IN_MEMORY_DB: "false" + USE_IN_MEMORY_DB: 'false' OSS_ACCESS_KEY_ID: placeholder OSS_ACCESS_KEY_SECRET: placeholder OSS_BUCKET_NAME: xiaoxia-autocut OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time - - name: Verify CI environment - shell: sh - run: | - set -eu - python3 --version - python3 -m pip --version - echo "CI environment is ready" + echo "Job started at $(date)"' + - name: Verify CI environment + shell: sh + run: 'set -eu - - name: Install dependencies - shell: sh - run: | - set -eu - python3 -m pip install -q -r requirements-base.txt - python3 -m pip install -q -r requirements.txt - python3 -m pip install -q -r requirements-dev.txt - pytest --version + python3 --version - - name: Install ffmpeg - shell: sh - run: | - set +e - if command -v ffmpeg > /dev/null 2>&1; then - echo "ffmpeg already installed: $(ffmpeg -version | head -1)" - exit 0 - fi - if command -v apt-get > /dev/null 2>&1; then - apt-get update -qq && apt-get install -y -qq ffmpeg - elif command -v yum > /dev/null 2>&1; then - yum install -y -q epel-release 2>/dev/null - yum install -y -q ffmpeg 2>/dev/null - if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then - dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null - dnf install -y -q ffmpeg 2>/dev/null - fi - elif command -v dnf > /dev/null 2>&1; then - dnf install -y -q ffmpeg 2>/dev/null - fi - if command -v ffmpeg > /dev/null 2>&1; then - echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)" - else - echo "Warning: ffmpeg installation failed or not available, some tests may be skipped" - fi + python3 -m pip --version - - name: Start Redis - shell: sh - run: | - set -eu - REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}" - echo "REDIS_CONTAINER=$REDIS_CONTAINER" >> "$GITHUB_ENV" - docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true - docker run -d --name "$REDIS_CONTAINER" \ - -P \ - --health-cmd "redis-cli ping" \ - --health-interval 2s \ - --health-timeout 2s \ - --health-retries 10 \ - redis:7-alpine - REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2) - echo "Redis port: $REDIS_PORT" - echo "REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0" >> "$GITHUB_ENV" - for i in $(seq 1 15); do - if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then - echo "Redis is ready on port $REDIS_PORT" - break - fi - echo "Waiting for Redis... ($i/15)" - sleep 2 - done - docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy + echo "CI environment is ready" - - name: Start PostgreSQL for integration tests - shell: sh - run: | - set -eu - PG_CONTAINER="ci-pg-${GITHUB_RUN_ID:-$$}" - echo "PG_CONTAINER=$PG_CONTAINER" >> "$GITHUB_ENV" - docker rm -f "$PG_CONTAINER" 2>/dev/null || true - docker run -d --name "$PG_CONTAINER" \ - --shm-size=256m \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=xiaoxia_saas \ - -P \ - --health-cmd "pg_isready -U postgres" \ - --health-interval 5s \ - --health-timeout 5s \ - --health-retries 12 \ - postgres:16 - # 获取随机映射的端口 - PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) - echo "PostgreSQL port: $PG_PORT" - echo "DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas" >> "$GITHUB_ENV" - for i in $(seq 1 30); do - if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then - echo "PostgreSQL is ready on port $PG_PORT" - break - fi - echo "Waiting for PostgreSQL... ($i/30)" - sleep 2 - done - docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy + ' + - name: Install dependencies + shell: sh + run: 'set -eu - - name: Apply migrations for integration tests - shell: sh - run: | - set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + python3 -m pip install -q -r requirements-base.txt - - name: Run integration tests - shell: sh - run: | - set -eu - python3 -m pip install -q pytest-rerunfailures - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \ - --source=apps/api/app,packages \ - --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ - --branch \ - -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance" - python3 -m coverage report --show-missing - python3 -m coverage xml -o coverage.xml - python3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证 + python3 -m pip install -q -r requirements.txt - - name: Run API performance baseline tests - shell: sh - continue-on-error: true - run: | - set +e - echo "=== API 性能基线测试 ===" - PERF_OUTPUT=$(mktemp) - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \ - -v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT" - PERF_EXIT=$? - - # 提取性能统计 - echo "" - echo "=== 性能测试摘要 ===" - grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据" - grep "PERF_RESULT:" "$PERF_OUTPUT" || echo "PERF_RESULT: 未找到详细结果" - - # 统计通过率 - TOTAL=$(grep -c "PERF_RESULT:" "$PERF_OUTPUT" || echo 0) - PASSED=$(grep "PERF_RESULT: PASS" "$PERF_OUTPUT" | wc -l) - FAILED=$(grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | wc -l) - - echo "" - echo "性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标" - - if [ "$FAILED" -gt 0 ]; then - echo "" - echo "⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:" - grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | while read line; do - echo " $line" - done - echo "" - echo "性能测试失败不阻塞主流水线,但建议尽快优化。" - else - echo "✅ 所有接口性能达标!" - fi - - rm -f "$PERF_OUTPUT" - # 始终返回 0,不阻塞流水线 - exit 0 + python3 -m pip install -q -r requirements-dev.txt + pytest --version - - name: Cleanup PostgreSQL & Redis - if: always() - shell: sh - run: | - docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true - docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true - echo "PostgreSQL container cleaned up" - echo "Redis container cleaned up" + ' + - name: Install ffmpeg + shell: sh + run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already\ + \ installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get\ + \ > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\n\ + elif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release\ + \ 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] &&\ + \ command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck\ + \ https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E\ + \ %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n\ + \ fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg\ + \ 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"\ + ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo\ + \ \"Warning: ffmpeg installation failed or not available, some tests may be\ + \ skipped\"\nfi\n" + - name: Start Redis + shell: sh + run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\"\ + \ >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\n\ + docker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli\ + \ ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries\ + \ 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp\ + \ | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\"\ + \ >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}'\ + \ \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis\ + \ is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis...\ + \ ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\ + \ \"$REDIS_CONTAINER\" | grep -q healthy\n" + - name: Start PostgreSQL for integration tests + shell: sh + run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\"\ + \ >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\n\ + docker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres\ + \ \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\ + \n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval\ + \ 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n\ + # 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\n\ + echo \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\"\ + \ >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}'\ + \ \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL\ + \ is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL...\ + \ ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\ + \ \"$PG_CONTAINER\" | grep -q healthy\n" + - name: Apply migrations for integration tests + shell: sh + run: 'set -eu - - name: Coverage summary - if: always() - shell: sh - env: - COVERAGE_THRESHOLD: "40" - run: | - set +e - echo "=== 覆盖率汇总 ===" - python3 scripts/ci_coverage_summary.py - - name: Notify CI failure - if: failure() - shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head - - name: Notify CI failure - Integration Tests - if: failure() - shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Integration Tests" python3 scripts/ci_notify_failure.py + ' + - name: Run integration tests + shell: sh + run: "set -eu\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"\ + $PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages\ + \ \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\"\ + \ \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns\ + \ 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\n\ + python3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40\ + \ > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n" + - name: Run API performance baseline tests + shell: sh + continue-on-error: true + run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"\ + $PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py\ + \ \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\n\ + PERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\"\ + \ \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\"\ + \ \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep\ + \ -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT:\ + \ PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"\ + $PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED\ + \ 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED\ + \ 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while\ + \ read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\ + \nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\n# 始终返回 0,不阻塞流水线\n\ + exit 0\n" + - name: Cleanup PostgreSQL & Redis + if: always() + shell: sh + run: 'docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true + docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true + echo "PostgreSQL container cleaned up" + + echo "Redis container cleaned up" + + ' + - name: Coverage summary + if: always() + shell: sh + env: + COVERAGE_THRESHOLD: '40' + run: 'set +e + + echo "=== 覆盖率汇总 ===" + + python3 scripts/ci_coverage_summary.py + + ' + - name: Notify CI failure + if: failure() + shell: sh + run: 'set +e + + echo "=== CI 失败通知 ===" + + FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py + + ' + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' + - name: Notify CI failure - Integration Tests + if: failure() + shell: sh + run: 'set +e + + echo "=== CI 失败通知 ===" + + FAILED_JOB="Integration Tests" python3 scripts/ci_notify_failure.py + + ' frontend-lint: name: Frontend Lint runs-on: host timeout-minutes: 10 - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time - - name: Install dependencies - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npm ci' + echo "Job started at $(date)"' + - name: Install dependencies (with cache) + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect\ + \ \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\"\ + \ >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ + \ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\"\ + \ \\\n -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20\ + \ \\\n sh -lc 'npm ci'\n" + - name: Run ESLint + shell: sh + run: "set -eu\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web\ + \ \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx eslint src\ + \ --ext .ts,.tsx --max-warnings 50'\n" + - name: Run TypeScript type check + shell: sh + run: "set -eu\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web\ + \ \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx tsc --noEmit'\n" + - name: Run Prettier check + shell: sh + run: "set -eu\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web\ + \ \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx prettier --check\ + \ \"src/**/*.{ts,tsx,md}\"'\n" + - name: Run Vitest tests + shell: sh + run: "set -eu\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web\ + \ \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx vitest run\ + \ src/test'\n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e - - name: Run ESLint - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50' + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) - - name: Run TypeScript type check - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx tsc --noEmit' + END=$(date +%s) - - name: Run Prettier check - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx prettier --check "src/**/*.{ts,tsx,md}"' + DURATION=$((END - START)) - - name: Run Vitest tests - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx vitest run src/test' + MINS=$((DURATION / 60)) - - name: Notify CI failure - if: failure() - shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py + SECS=$((DURATION % 60)) + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' + - name: Notify CI failure + if: failure() + shell: sh + run: 'set +e + + echo "=== CI 失败通知 ===" + + FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py + + ' build-staging-api: name: Build Staging API Image runs-on: saas timeout-minutes: 20 - needs: [validate, frontend-lint] - - if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') - + needs: + - validate + - frontend-lint + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name + == 'develop') steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + + ' - name: Setup cache strategy shell: sh - run: | - set -eu - # develop/main 分支写回缓存,其他分支只读 - if [ "${GITHUB_REF_NAME}" = "develop" ] || [ "${GITHUB_REF_NAME}" = "main" ]; then - echo "CACHE_MODE=read-write" >> $GITHUB_ENV - echo "Cache mode: read-write (will push cache)" - else - echo "CACHE_MODE=read-only" >> $GITHUB_ENV - echo "Cache mode: read-only" - fi - + run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"\ + develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\"\ + \ >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n\ + \ echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\ + \nfi\n" - name: Setup buildx builder (docker-container driver) shell: sh - run: | - set -eu - # 确保使用 docker-container driver 以支持 cache export 功能 - if ! docker buildx inspect ci-builder > /dev/null 2>&1; then - docker buildx create --use --name ci-builder --driver docker-container - echo "Created ci-builder (docker-container driver)" - else - docker buildx use ci-builder - echo "Using existing ci-builder" - fi - docker buildx inspect --bootstrap - + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker\ + \ buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create\ + \ --use --name ci-builder --driver docker-container\n echo \"Created ci-builder\ + \ (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo\ + \ \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - name: Build and push API image (buildx cache) shell: sh - run: | - set -eu - REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - IMAGE_NAME="xiaoxia-saas-api" - CACHE_REF="${REGISTRY}/api-cache:develop" + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"\ + xiaoxia-saas-api\"\nCACHE_REF=\"${REGISTRY}/api-cache:develop\"\n\nCACHE_FROM=\"\ + type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\"\ + \ = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\ + \n echo \"Building API image with read-write cache...\"\n docker buildx\ + \ build --build-arg APP_VERSION=\"${GITHUB_SHA}\" \ + \ --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" \ + \ -f infra/docker/api.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nelse\n echo \"Building API image with\ + \ read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"\ + ${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f\ + \ infra/docker/api.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nfi\necho \"API image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e - CACHE_FROM="type=registry,ref=${CACHE_REF},ignore-error=true" + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) - if [ "${CACHE_MODE}" = "read-write" ]; then - CACHE_TO="type=registry,ref=${CACHE_REF},mode=max" - echo "Building API image with read-write cache..." - docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" --cache-to "${CACHE_TO}" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . - else - echo "Building API image with read-only cache..." - docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . - fi - echo "API image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" + END=$(date +%s) + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Staging API Image" python3 scripts/ci_notify_failure.py + ' build-staging-worker: name: Build Staging Worker Image runs-on: saas timeout-minutes: 20 - needs: [validate, frontend-lint] - - if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') - + needs: + - validate + - frontend-lint + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name + == 'develop') steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + + ' - name: Setup cache strategy shell: sh - run: | - set -eu - # develop/main 分支写回缓存,其他分支只读 - if [ "${GITHUB_REF_NAME}" = "develop" ] || [ "${GITHUB_REF_NAME}" = "main" ]; then - echo "CACHE_MODE=read-write" >> $GITHUB_ENV - echo "Cache mode: read-write (will push cache)" - else - echo "CACHE_MODE=read-only" >> $GITHUB_ENV - echo "Cache mode: read-only" - fi - + run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"\ + develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\"\ + \ >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n\ + \ echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\ + \nfi\n" - name: Setup buildx builder (docker-container driver) shell: sh - run: | - set -eu - # 确保使用 docker-container driver 以支持 cache export 功能 - if ! docker buildx inspect ci-builder > /dev/null 2>&1; then - docker buildx create --use --name ci-builder --driver docker-container - echo "Created ci-builder (docker-container driver)" - else - docker buildx use ci-builder - echo "Using existing ci-builder" - fi - docker buildx inspect --bootstrap - + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker\ + \ buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create\ + \ --use --name ci-builder --driver docker-container\n echo \"Created ci-builder\ + \ (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo\ + \ \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - name: Build and push Worker image (buildx cache) shell: sh - run: | - set -eu - REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - IMAGE_NAME="xiaoxia-saas-worker" - CACHE_REF="${REGISTRY}/worker-cache:develop" + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"\ + xiaoxia-saas-worker\"\nCACHE_REF=\"${REGISTRY}/worker-cache:develop\"\n\n\ + CACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"\ + ${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\ + \n echo \"Building Worker image with read-write cache...\"\n docker buildx\ + \ build --build-arg APP_VERSION=\"${GITHUB_SHA}\" \ + \ --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" \ + \ -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nelse\n echo \"Building Worker image with\ + \ read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"\ + ${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f\ + \ infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nfi\necho \"Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e - CACHE_FROM="type=registry,ref=${CACHE_REF},ignore-error=true" + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) - if [ "${CACHE_MODE}" = "read-write" ]; then - CACHE_TO="type=registry,ref=${CACHE_REF},mode=max" - echo "Building Worker image with read-write cache..." - docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" --cache-to "${CACHE_TO}" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . - else - echo "Building Worker image with read-only cache..." - docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . - fi - echo "Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" + END=$(date +%s) + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Staging Worker Image" python3 scripts/ci_notify_failure.py + ' build-staging-web: name: Build Staging Web Image runs-on: saas timeout-minutes: 20 - needs: [validate, frontend-lint] - - if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') - + needs: + - validate + - frontend-lint + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name + == 'develop') steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + + ' - name: Setup cache strategy shell: sh - run: | - set -eu - # develop/main 分支写回缓存,其他分支只读 - if [ "${GITHUB_REF_NAME}" = "develop" ] || [ "${GITHUB_REF_NAME}" = "main" ]; then - echo "CACHE_MODE=read-write" >> $GITHUB_ENV - echo "Cache mode: read-write (will push cache)" - else - echo "CACHE_MODE=read-only" >> $GITHUB_ENV - echo "Cache mode: read-only" - fi - + run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"\ + develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\"\ + \ >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n\ + \ echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\ + \nfi\n" - name: Build frontend assets (npm build) shell: sh - run: | - set -eu - NPM_CACHE_VOLUME="xiaoxia-npm-cache" - if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then - docker volume create "$NPM_CACHE_VOLUME" >/dev/null - echo "Created npm cache volume: $NPM_CACHE_VOLUME" - fi - - docker run --rm -v "$PWD:/workspace" -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm ci && npm run build" - - test -f apps/web/dist/index.html - echo "Frontend build complete: $(ls apps/web/dist/ | head -5)" - + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect\ + \ \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\"\ + \ >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ + \ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\"\ + \ -w /workspace/apps/web docker.m.daocloud.io/library/node:20\ + \ sh -lc \"npm ci && npm run build\"\n\ntest -f apps/web/dist/index.html\n\ + echo \"Frontend build complete: $(ls apps/web/dist/ | head -5)\"\n" - name: Setup buildx builder (docker-container driver) shell: sh - run: | - set -eu - # 确保使用 docker-container driver 以支持 cache export 功能 - if ! docker buildx inspect ci-builder > /dev/null 2>&1; then - docker buildx create --use --name ci-builder --driver docker-container - echo "Created ci-builder (docker-container driver)" - else - docker buildx use ci-builder - echo "Using existing ci-builder" - fi - docker buildx inspect --bootstrap - + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker\ + \ buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create\ + \ --use --name ci-builder --driver docker-container\n echo \"Created ci-builder\ + \ (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo\ + \ \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - name: Build and push Web image (buildx cache) shell: sh - run: | - set -eu - REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - IMAGE_NAME="xiaoxia-saas-web" - CACHE_REF="${REGISTRY}/web-cache:develop" - NGINX_CONF="infra/docker/nginx-staging.conf" + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"\ + xiaoxia-saas-web\"\nCACHE_REF=\"${REGISTRY}/web-cache:develop\"\nNGINX_CONF=\"\ + infra/docker/nginx-staging.conf\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\ + \n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\ + \n echo \"Building Web image with read-write cache...\"\n docker buildx\ + \ build --cache-from \"${CACHE_FROM}\" --cache-to\ + \ \"${CACHE_TO}\" -f infra/docker/web-artifact.Dockerfile \ + \ --build-arg \"NGINX_CONF=${NGINX_CONF}\" -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nelse\n echo \"Building Web image with\ + \ read-only cache...\"\n docker buildx build --cache-from \"\ + ${CACHE_FROM}\" -f infra/docker/web-artifact.Dockerfile \ + \ --build-arg \"NGINX_CONF=${NGINX_CONF}\" -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nfi\necho \"Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e - CACHE_FROM="type=registry,ref=${CACHE_REF},ignore-error=true" + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) - if [ "${CACHE_MODE}" = "read-write" ]; then - CACHE_TO="type=registry,ref=${CACHE_REF},mode=max" - echo "Building Web image with read-write cache..." - docker buildx build --cache-from "${CACHE_FROM}" --cache-to "${CACHE_TO}" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . - else - echo "Building Web image with read-only cache..." - docker buildx build --cache-from "${CACHE_FROM}" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . - fi - echo "Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" + END=$(date +%s) + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Staging Web Image" python3 scripts/ci_notify_failure.py + ' deploy-staging: name: Deploy Staging (Watchtower auto-deploy) runs-on: saas timeout-minutes: 15 - needs: [build-staging-api, build-staging-worker, build-staging-web] - - if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') - + needs: + - build-staging-api + - build-staging-worker + - build-staging-web + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name + == 'develop') steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + ' - name: Tag and push :staging images (Watchtower auto-update) shell: sh - run: | - set -eu - REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - - for svc in api worker web; do - echo "Pulling ${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA} ..." - docker pull "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" - docker tag "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" "${REGISTRY}/xiaoxia-saas-${svc}:staging" - docker push "${REGISTRY}/xiaoxia-saas-${svc}:staging" - echo "$svc :staging tagged and pushed" - done - echo "All :staging images pushed. Watchtower will auto-deploy within 60s." - + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\n\nfor\ + \ svc in api worker web; do\n echo \"Pulling ${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\ + \ ...\"\n docker pull \"${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\"\n\ + \ docker tag \"${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\" \"${REGISTRY}/xiaoxia-saas-${svc}:staging\"\ + \n docker push \"${REGISTRY}/xiaoxia-saas-${svc}:staging\"\n echo \"$svc\ + \ :staging tagged and pushed\"\ndone\necho \"All :staging images pushed. Watchtower\ + \ will auto-deploy within 60s.\"\n" - name: Wait for Watchtower update + smoke test shell: sh - run: | - set -eu - echo "Waiting 90s for Watchtower to detect new image and restart containers..." - sleep 90 - - echo "--- Smoke test 1: Health check ---" - for i in $(seq 1 12); do - HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) && break - echo " Attempt $i/12: not ready yet, waiting 5s..." - sleep 5 - done - if [ -z "$HEALTH" ]; then - echo "FAIL: health endpoint unreachable after 60s" - exit 1 - fi - echo "Health OK: $HEALTH" - - echo "--- Smoke test 2: Login API (expect 401) ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ - https://staging-api.xiaoxiajianji.com/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"smoke@test.com","password":"wrong"}') - if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then - echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" - exit 1 - fi - echo "Login API OK: HTTP $HTTP_CODE" - - echo "--- Smoke test 3: API docs endpoint ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging-api.xiaoxiajianji.com/docs) - if [ "$HTTP_CODE" != "200" ]; then - echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)" - exit 1 - fi - echo "Docs endpoint OK: HTTP $HTTP_CODE" - - echo "--- Smoke test 4: Web frontend ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging.xiaoxiajianji.com/) - if [ "$HTTP_CODE" != "200" ]; then - echo "FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)" - exit 1 - fi - echo "Web frontend OK: HTTP $HTTP_CODE" - - echo "" - echo "=== All smoke tests passed! ===" - echo "Branch: ${GITHUB_REF_NAME}" - echo "Commit: ${GITHUB_SHA}" - + run: "set -eu\necho \"Waiting 90s for Watchtower to detect new image and restart\ + \ containers...\"\nsleep 90\n\necho \"--- Smoke test 1: Health check ---\"\ + \nfor i in $(seq 1 12); do\n HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health)\ + \ && break\n echo \" Attempt $i/12: not ready yet, waiting 5s...\"\n sleep\ + \ 5\ndone\nif [ -z \"$HEALTH\" ]; then\n echo \"FAIL: health endpoint unreachable\ + \ after 60s\"\n exit 1\nfi\necho \"Health OK: $HEALTH\"\n\necho \"--- Smoke\ + \ test 2: Login API (expect 401) ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w\ + \ \"%{http_code}\" --max-time 10 -X POST \\\n https://staging-api.xiaoxiajianji.com/api/v1/auth/login\ + \ \\\n -H \"Content-Type: application/json\" \\\n -d '{\"email\":\"smoke@test.com\"\ + ,\"password\":\"wrong\"}')\nif [ \"$HTTP_CODE\" != \"401\" ] && [ \"$HTTP_CODE\"\ + \ != \"422\" ]; then\n echo \"FAIL: login returned HTTP $HTTP_CODE (expected\ + \ 401 or 422)\"\n exit 1\nfi\necho \"Login API OK: HTTP $HTTP_CODE\"\n\n\ + echo \"--- Smoke test 3: API docs endpoint ---\"\nHTTP_CODE=$(curl -s -o /dev/null\ + \ -w \"%{http_code}\" --max-time 10 https://staging-api.xiaoxiajianji.com/docs)\n\ + if [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: /docs returned HTTP\ + \ $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Docs endpoint OK: HTTP\ + \ $HTTP_CODE\"\n\necho \"--- Smoke test 4: Web frontend ---\"\nHTTP_CODE=$(curl\ + \ -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://staging.xiaoxiajianji.com/)\n\ + if [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: web frontend returned\ + \ HTTP $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Web frontend OK:\ + \ HTTP $HTTP_CODE\"\n\necho \"\"\necho \"=== All smoke tests passed! ===\"\ + \necho \"Branch: ${GITHUB_REF_NAME}\"\necho \"Commit: ${GITHUB_SHA}\"\n" - name: Notify CI success if: success() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 成功通知 ===" + SUCCESS_JOB="Staging部署成功" python3 scripts/ci_notify_success.py + ' + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Deploy Staging" python3 scripts/ci_notify_failure.py - + ' staging-e2e: name: Staging E2E Tests runs-on: saas timeout-minutes: 15 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Run Playwright E2E on staging shell: sh - run: | - set -eu - docker run --rm --ipc=host \ - -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ - -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ - -e E2E_BROWSER_CHANNEL=chromium \ - -e PLAYWRIGHT_HEADLESS=1 \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ - sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts" + run: "set -eu\ndocker run --rm --ipc=host \\\n -e E2E_BASE_URL=https://staging.xiaoxiajianji.com\ + \ \\\n -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \\\n\ + \ -e E2E_BROWSER_CHANNEL=chromium \\\n -e PLAYWRIGHT_HEADLESS=1 \\\n -v\ + \ \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy\ + \ \\\n sh -lc \"npm ci && npx playwright test --reporter=line --project=chromium\ + \ e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts\"\ + \n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Staging E2E Tests" python3 scripts/ci_notify_failure.py - + ' staging-api-tests: name: Staging API Integration Tests runs-on: saas timeout-minutes: 10 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Run API integration tests on staging shell: sh - run: | - set -eu - docker run --rm \ - -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ - -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ - sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts' + run: "set -eu\ndocker run --rm \\\n -e E2E_BASE_URL=https://staging.xiaoxiajianji.com\ + \ \\\n -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \\\n\ + \ -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy\ + \ \\\n sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts\ + \ e2e/test_asset.spec.ts e2e/test_project.spec.ts'\n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Staging API Integration Tests" python3 scripts/ci_notify_failure.py - - + ' build-production-api: name: Build Production API Image runs-on: saas timeout-minutes: 20 - needs: [validate, frontend-lint] - + needs: + - validate + - frontend-lint if: startsWith(github.ref, 'refs/tags/v') - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + ' - name: Setup buildx builder (docker-container driver) shell: sh - run: | - set -eu - # 确保使用 docker-container driver 以支持 cache export 功能 - if ! docker buildx inspect ci-builder > /dev/null 2>&1; then - docker buildx create --use --name ci-builder --driver docker-container - echo "Created ci-builder (docker-container driver)" - else - docker buildx use ci-builder - echo "Using existing ci-builder" - fi - docker buildx inspect --bootstrap - + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker\ + \ buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create\ + \ --use --name ci-builder --driver docker-container\n echo \"Created ci-builder\ + \ (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo\ + \ \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - name: Build and push API image (buildx cache) shell: sh - run: | - set -eu + run: 'set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-api" + VERSION="${GITHUB_REF_NAME}" + CACHE_REF="${REGISTRY}/api-cache:main" + echo "Building Production API image: ${VERSION}" - docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + + docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from + "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f + infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + echo "Production API image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + ' + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production API Image" python3 scripts/ci_notify_failure.py + ' build-production-worker: name: Build Production Worker Image runs-on: saas timeout-minutes: 20 - needs: [validate, frontend-lint] - + needs: + - validate + - frontend-lint if: startsWith(github.ref, 'refs/tags/v') - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + ' - name: Setup buildx builder (docker-container driver) shell: sh - run: | - set -eu - # 确保使用 docker-container driver 以支持 cache export 功能 - if ! docker buildx inspect ci-builder > /dev/null 2>&1; then - docker buildx create --use --name ci-builder --driver docker-container - echo "Created ci-builder (docker-container driver)" - else - docker buildx use ci-builder - echo "Using existing ci-builder" - fi - docker buildx inspect --bootstrap - + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker\ + \ buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create\ + \ --use --name ci-builder --driver docker-container\n echo \"Created ci-builder\ + \ (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo\ + \ \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - name: Build and push Worker image (buildx cache) shell: sh - run: | - set -eu + run: 'set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-worker" + VERSION="${GITHUB_REF_NAME}" + CACHE_REF="${REGISTRY}/worker-cache:main" + echo "Building Production Worker image: ${VERSION}" - docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + + docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from + "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f + infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + echo "Production Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + ' + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production Worker Image" python3 scripts/ci_notify_failure.py + ' build-production-web: name: Build Production Web Image runs-on: saas timeout-minutes: 20 - needs: [validate, frontend-lint] - + needs: + - validate + - frontend-lint if: startsWith(github.ref, 'refs/tags/v') - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u + xiaoxia --password-stdin + echo "Docker login successful" + ' - name: Build frontend assets (npm build) shell: sh - run: | - set -eu - NPM_CACHE_VOLUME="xiaoxia-npm-cache" - if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then - docker volume create "$NPM_CACHE_VOLUME" >/dev/null - fi - - docker run --rm -v "$PWD:/workspace" -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm ci && npm run build" - - test -f apps/web/dist/index.html - echo "Frontend build complete" - + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect\ + \ \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\"\ + \ >/dev/null\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" \ + \ -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" \ + \ -w /workspace/apps/web docker.m.daocloud.io/library/node:20 \ + \ sh -lc \"npm ci && npm run build\"\n\ntest -f apps/web/dist/index.html\n\ + echo \"Frontend build complete\"\n" - name: Setup buildx builder (docker-container driver) shell: sh - run: | - set -eu - # 确保使用 docker-container driver 以支持 cache export 功能 - if ! docker buildx inspect ci-builder > /dev/null 2>&1; then - docker buildx create --use --name ci-builder --driver docker-container - echo "Created ci-builder (docker-container driver)" - else - docker buildx use ci-builder - echo "Using existing ci-builder" - fi - docker buildx inspect --bootstrap - + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker\ + \ buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create\ + \ --use --name ci-builder --driver docker-container\n echo \"Created ci-builder\ + \ (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo\ + \ \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - name: Build and push Web image (buildx cache) shell: sh - run: | - set -eu + run: 'set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-web" + VERSION="${GITHUB_REF_NAME}" + CACHE_REF="${REGISTRY}/web-cache:main" + NGINX_CONF="infra/docker/nginx-production.conf" + echo "Building Production Web image: ${VERSION}" - docker buildx build --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + + docker buildx build --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to + "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/web-artifact.Dockerfile --build-arg + "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + echo "Production Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + ' - name: Cleanup old Docker images if: always() shell: sh - run: | - set -eu - if [ -f scripts/cleanup_old_images.sh ]; then - chmod +x scripts/cleanup_old_images.sh - scripts/cleanup_old_images.sh - else - echo "Cleanup script not found, doing basic prune..." - docker image prune -f 2>/dev/null || true - fi - echo "Disk usage after cleanup:" - df -h / | tail -1 + run: "set -eu\nif [ -f scripts/cleanup_old_images.sh ]; then\n chmod +x scripts/cleanup_old_images.sh\n\ + \ scripts/cleanup_old_images.sh\nelse\n echo \"Cleanup script not found,\ + \ doing basic prune...\"\n docker image prune -f 2>/dev/null || true\nfi\n\ + echo \"Disk usage after cleanup:\"\ndf -h / | tail -1\n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production Web Image" python3 scripts/ci_notify_failure.py + ' deploy-production: name: Deploy Production runs-on: saas timeout-minutes: 20 if: startsWith(github.ref, 'refs/tags/v') - needs: [build-production-api, build-production-worker, build-production-web] - + needs: + - build-production-api + - build-production-worker + - build-production-web steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n\ + \ try:\n with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\n\ + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix\ + \ = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n\ + \ name = member.name\n if name == root_prefix[:-1]:\n \ + \ continue\n if name.startswith(root_prefix):\n member.name\ + \ = name[len(root_prefix):]\n if member.name:\n tar.extract(member,\ + \ '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + + echo "Job started at $(date)"' - name: Install SSH client shell: sh - run: | - set -eu + run: 'set -eu + apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 + echo "openssh-client installed" + ' - name: Deploy production over SSH (Registry pull) shell: sh env: @@ -1798,186 +1697,173 @@ jobs: PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eux - production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - production_user="${PRODUCTION_SSH_USER:-root}" - echo "Host: $production_host" - echo "User: $production_user" - echo "Home: $HOME" - echo "Whoami: $(whoami)" - - mkdir -p ~/.ssh - - # 查找可用的SSH密钥 - key_path="" - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using key: $key_path (builder key)" - elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then - key_path="$HOME/.ssh/xiaoxia_runtime_builder" - echo "Using key: $key_path (home key)" - elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path" - chmod 600 "$key_path" - echo "Using key from PRODUCTION_SSH_KEY secret" - else - echo "ERROR: No SSH key available" - ls -la ~/.ssh/ 2>/dev/null || true - ls -la /root/.ssh/ 2>/dev/null || true - exit 1 - fi - - ssh-keyscan -p 22222 -H "$production_host" >> ~/.ssh/known_hosts 2>/dev/null - echo "SSH keyscan done" - - # 测试SSH连接 - ssh -p 22222 -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "echo SSH_CONNECTION_OK && hostname" - echo "SSH connection verified" - - # Registry 方式部署脚本(base64 编码避免转义问题) - DEPLOY_B64="IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=" - - echo "$DEPLOY_B64" | base64 -d | ssh -p 22222 -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh" - + run: "set -eux\nproduction_host=\"${PRODUCTION_SSH_HOST:-47.98.113.167}\"\n\ + production_user=\"${PRODUCTION_SSH_USER:-root}\"\necho \"Host: $production_host\"\ + \necho \"User: $production_user\"\necho \"Home: $HOME\"\necho \"Whoami: $(whoami)\"\ + \n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder\ + \ ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using\ + \ key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\"\ + \ ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using\ + \ key: $key_path (home key)\"\nelif [ -n \"${PRODUCTION_SSH_KEY:-}\" ]; then\n\ + \ key_path=\"$HOME/.ssh/id_ed25519\"\n printf '%s\\n' \"$PRODUCTION_SSH_KEY\"\ + \ > \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from PRODUCTION_SSH_KEY\ + \ secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/\ + \ 2>/dev/null || true\n ls -la /root/.ssh/ 2>/dev/null || true\n exit 1\n\ + fi\n\nssh-keyscan -p 22222 -H \"$production_host\" >> ~/.ssh/known_hosts 2>/dev/null\n\ + echo \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p 22222 -i \"$key_path\" -o StrictHostKeyChecking=no\ + \ \"${production_user}@${production_host}\" \"echo SSH_CONNECTION_OK && hostname\"\ + \necho \"SSH connection verified\"\n\n# Registry 方式部署脚本(base64 编码避免转义问题)\n\ + DEPLOY_B64=\"IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=\"\ + \n\necho \"$DEPLOY_B64\" | base64 -d | ssh -p 22222 -i \"$key_path\" \"$production_user@$production_host\"\ + \ \"IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh\"\ + \n" - name: Production smoke test (健康检查门禁) if: success() shell: sh - run: | - set -eu - API_BASE="https://api.xiaoxiajianji.com" - WEB_BASE="https://saas.xiaoxiajianji.com" - - echo "=== 生产部署门禁:外部健康检查 ===" - echo "等待服务启动稳定(30s)..." - sleep 30 - - echo "--- Check 1: API health endpoint ---" - for i in $(seq 1 20); do - HEALTH=$(curl -sf --max-time 10 "${API_BASE}/health") && break - echo " Attempt $i/20: not ready yet, waiting 5s..." - sleep 5 - done - if [ -z "$HEALTH" ]; then - echo "FAIL: API /health unreachable after 100s" - echo "生产环境健康检查未通过,部署失败!" - echo "(回滚机制待接入,当前需手动回滚)" - exit 1 - fi - echo "API health OK: $HEALTH" - - echo "--- Check 2: API login endpoint (expect 401/422) ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST "${API_BASE}/api/v1/auth/login" -H "Content-Type: application/json" -d '{"email":"smoke@test.com","password":"wrong"}') - if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then - echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" - exit 1 - fi - echo "Login API OK: HTTP $HTTP_CODE" - - echo "--- Check 3: Web frontend ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${WEB_BASE}/") - if [ "$HTTP_CODE" != "200" ]; then - echo "FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)" - exit 1 - fi - echo "Web frontend OK: HTTP $HTTP_CODE" - - echo "" - echo "=== ✅ 生产环境健康检查全部通过 ===" - echo "Version: ${GITHUB_REF_NAME}" - + run: "set -eu\nAPI_BASE=\"https://api.xiaoxiajianji.com\"\nWEB_BASE=\"https://saas.xiaoxiajianji.com\"\ + \n\necho \"=== 生产部署门禁:外部健康检查 ===\"\necho \"等待服务启动稳定(30s)...\"\nsleep 30\n\n\ + echo \"--- Check 1: API health endpoint ---\"\nfor i in $(seq 1 20); do\n\ + \ HEALTH=$(curl -sf --max-time 10 \"${API_BASE}/health\") && break\n echo\ + \ \" Attempt $i/20: not ready yet, waiting 5s...\"\n sleep 5\ndone\nif [\ + \ -z \"$HEALTH\" ]; then\n echo \"FAIL: API /health unreachable after 100s\"\ + \n echo \"生产环境健康检查未通过,部署失败!\"\n echo \"(回滚机制待接入,当前需手动回滚)\"\n exit 1\nfi\n\ + echo \"API health OK: $HEALTH\"\n\necho \"--- Check 2: API login endpoint\ + \ (expect 401/422) ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\"\ + \ --max-time 10 -X POST \"${API_BASE}/api/v1/auth/login\" \ + \ -H \"Content-Type: application/json\" -d '{\"email\":\"smoke@test.com\"\ + ,\"password\":\"wrong\"}')\nif [ \"$HTTP_CODE\" != \"401\" ] && [ \"$HTTP_CODE\"\ + \ != \"422\" ]; then\n echo \"FAIL: login returned HTTP $HTTP_CODE (expected\ + \ 401 or 422)\"\n exit 1\nfi\necho \"Login API OK: HTTP $HTTP_CODE\"\n\n\ + echo \"--- Check 3: Web frontend ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w\ + \ \"%{http_code}\" --max-time 10 \"${WEB_BASE}/\")\nif [ \"$HTTP_CODE\" !=\ + \ \"200\" ]; then\n echo \"FAIL: web frontend returned HTTP $HTTP_CODE (expected\ + \ 200)\"\n exit 1\nfi\necho \"Web frontend OK: HTTP $HTTP_CODE\"\n\necho\ + \ \"\"\necho \"=== ✅ 生产环境健康检查全部通过 ===\"\necho \"Version: ${GITHUB_REF_NAME}\"\ + \n" - name: Notify CI success if: success() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 成功通知 ===" + SUCCESS_JOB="生产部署成功" python3 scripts/ci_notify_success.py + ' + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Deploy Production" python3 scripts/ci_notify_failure.py - + ' production-e2e: name: Production Browser E2E runs-on: saas timeout-minutes: 15 if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - # Retry up to 5 times with backoff for transient 5xx errors - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request,\ + \ urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\ + \nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token\ + \ {os.environ['GITHUB_TOKEN']}\"})\n# Retry up to 5 times with backoff for\ + \ transient 5xx errors\nlast_err = None\nfor attempt in range(5):\n try:\n\ + \ with urllib.request.urlopen(request, timeout=120) as response:\n\ + \ archive = response.read()\n break\n except urllib.error.HTTPError\ + \ as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n\ + \ wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code},\ + \ retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n\ + \ continue\n raise\n except Exception as e:\n \ + \ last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n\ + \ print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n\ + \ raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz')\ + \ as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n\ + \ for member in tar.getmembers():\n name = member.name\n \ + \ if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n\ + \ member.name = name[len(root_prefix):]\n if member.name:\n\ + \ tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'date +%s > /tmp/job_start_time + echo "Job started at $(date)"' - name: Run production browser E2E shell: sh - run: | - set -eu - docker run --rm --ipc=host \ - -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \ - -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \ - -e E2E_BROWSER_CHANNEL=chromium \ - -e PLAYWRIGHT_HEADLESS=1 \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ - sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' + run: "set -eu\ndocker run --rm --ipc=host \\\n -e E2E_BASE_URL=https://saas.xiaoxiajianji.com\ + \ \\\n -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \\\n -e E2E_BROWSER_CHANNEL=chromium\ + \ \\\n -e PLAYWRIGHT_HEADLESS=1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web\ + \ \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n \ + \ sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium\ + \ e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts\ + \ e2e/core-titles.spec.ts'\n" + - name: Job duration summary + if: always() + shell: sh + run: 'set +e + START=$(cat /tmp/job_start_time 2>/dev/null || echo 0) + + END=$(date +%s) + + DURATION=$((END - START)) + + MINS=$((DURATION / 60)) + + SECS=$((DURATION % 60)) + + echo "========================================" + + echo " Job: ${GITEA_JOB_NAME:-unknown}" + + echo " Duration: ${MINS}m${SECS}s (${DURATION}s)" + + echo " Runner: ${GITEA_RUNNER_NAME:-unknown}" + + echo "========================================" + + ' - name: Notify CI failure if: failure() shell: sh - run: | - set +e + run: 'set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Production Browser E2E" python3 scripts/ci_notify_failure.py + '