Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 1a5bb1ab43 feat(ci): optimize web image build with multi-stage Dockerfile + buildx cache
Tests / test (pull_request) Failing after 0s
Tests / lint (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-13 15:11:28 +08:00
xiaoxia bf47e69fa4 feat(ci): add node_modules cache for frontend-lint job 2026-07-13 15:10:44 +08:00
xiaoxia b947bf7569 feat(ci): add node_modules cache for frontend-lint job 2026-07-13 15:09:47 +08:00
xiaoxia f110bc6f16 feat: web.Dockerfile add buildkit cache for npm install 2026-07-13 15:08:49 +08:00
8 changed files with 37 additions and 417 deletions
-165
View File
@@ -1,165 +0,0 @@
name: Auto Approve CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review]
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-l1
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
# 根据目标分支决定检查哪些门禁
TARGET_BRANCH="${GITHUB_BASE_REF}"
echo "目标分支: ${TARGET_BRANCH}"
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
elif [ "$TARGET_BRANCH" = "main" ]; then
# main分支只检查required statuses: Validate + Frontend Lint
# 不检查Tests/test(不是required门禁)
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"Tests / test (pull_request)"
)
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING reviewGitea API需要先创建再提交)
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
+12 -122
View File
@@ -1,131 +1,21 @@
name: Auto Merge PRs (main)
name: Auto Merge PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
auto-merge:
name: Auto Merge on CI Green + Approved (main)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'main'
timeout-minutes: 30
steps:
- name: Checkout code
- name: Checkout
uses: actions/checkout@v3
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
- name: Auto merge develop PRs
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合main分支
if [ "$BASE_REF" != "main" ]; then
echo "Skip: 目标分支不是main"
exit 0
fi
# main分支门禁:Validate + Frontend Lint
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"Tests / test (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
echo "检查门禁: ${#CONTEXTS[@]} 项"
echo
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行mergemain分支用merge,保留历史)
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"merge","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
echo "合并失败(405),可能有冲突或门禁未通过"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
bash scripts/auto_merge_prs.sh develop
- name: Auto merge main PRs (release only)
run: |
bash scripts/auto_merge_prs.sh main
+11 -21
View File
@@ -128,27 +128,6 @@ jobs:
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
python3 scripts/check_schema_metadata.py
- name: Start Redis for unit tests
shell: sh
run: |
set -eu
docker rm -f ci-redis-validate 2>/dev/null || true
docker run -d --name ci-redis-validate \
-p 6379:6379 \
--health-cmd "redis-cli ping" \
--health-interval 2s \
--health-timeout 2s \
--health-retries 15 \
redis:7-alpine
for i in $(seq 1 20); do
if docker inspect --format='{{.State.Health.Status}}' ci-redis-validate 2>/dev/null | grep -q healthy; then
echo "Redis is ready"
break
fi
echo "Waiting for Redis... ($i/20)"
sleep 1
done
docker inspect --format='{{.State.Health.Status}}' ci-redis-validate | grep -q healthy
- name: Run unit tests
shell: sh
env:
@@ -213,6 +192,7 @@ jobs:
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- name: Checkout code
@@ -238,7 +218,17 @@ jobs:
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
rm -f /tmp/repo.tar.gz
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: apps/web/node_modules
key: ${{ runner.os }}-node-web-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-web-
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
shell: sh
run: |
set -eu
+2 -6
View File
@@ -11,11 +11,9 @@ jobs:
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
python - <<'PY'
import io
import os
import tarfile
@@ -95,11 +93,9 @@ jobs:
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
python - <<'PY'
import io
import os
import tarfile
+3 -1
View File
@@ -5,7 +5,9 @@ ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
ENV VITE_API_URL=$VITE_API_URL
COPY apps/web/package.json apps/web/package-lock.json ./apps/web/
WORKDIR /app/apps/web
RUN npm config set registry https://registry.npmmirror.com \
RUN \
--mount=type=cache,target=/root/.npm \
npm config set registry https://registry.npmmirror.com \
&& npm ci
COPY apps/web/ ./
RUN npm run build
+9 -12
View File
@@ -72,28 +72,25 @@ else
fi
echo "=== Building Web image (with buildx cache) ==="
# 先构建前端产物
docker run --rm \
-v "$PWD:/workspace" \
-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
# 使用多阶段 Dockerfile 构建,配合 buildx cache 缓存 npm 安装和构建产物
WEB_NGINX_CONF="${WEB_NGINX_CONF:-infra/docker/nginx-production.conf}"
WEB_API_URL="${VITE_API_URL:-https://saas-api.xiaoxiajianji.com}"
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-f infra/docker/web.Dockerfile \
--build-arg NGINX_CONF="${WEB_NGINX_CONF}" \
--build-arg VITE_API_URL="${WEB_API_URL}" \
-t "$WEB_IMAGE" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-f infra/docker/web.Dockerfile \
--build-arg NGINX_CONF="${WEB_NGINX_CONF}" \
--build-arg VITE_API_URL="${WEB_API_URL}" \
-t "$WEB_IMAGE" \
.
fi
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""检查指定commit的CI status状态。
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
返回: 打印状态 (success/failure/pending/error)
"""
import json
import sys
import urllib.error
import urllib.request
def main():
if len(sys.argv) != 5:
print("pending")
return
token = sys.argv[1]
repo = sys.argv[2]
sha = sys.argv[3]
target_context = sys.argv[4]
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
statuses = json.loads(resp.read().decode())
except Exception:
print("pending")
return
# API返回按时间倒序,第一个就是最新的
for s in statuses:
if s.get("context") == target_context:
print(s.get("status", "pending"))
return
print("pending")
if __name__ == "__main__":
main()
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python3
"""检查PR是否有至少N个APPROVED审批。
用法: python3 check_pr_approval.py <token> <repo> <pr_number> <min_approval>
返回: 打印 "approved""pending"
"""
import json
import sys
import urllib.request
def main():
if len(sys.argv) != 5:
print("pending")
return
token = sys.argv[1]
repo = sys.argv[2]
pr_number = sys.argv[3]
min_approval = int(sys.argv[4])
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews"
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
reviews = json.loads(resp.read().decode())
except Exception:
print("pending")
return
# 统计APPROVED的人数(去重,同一人多次审批只算一次)
approvers = set()
for r in reviews:
if r.get("state") == "APPROVED":
approvers.add(r.get("user", {}).get("login", ""))
if len(approvers) >= min_approval:
print(f"approved ({len(approvers)})")
else:
print(f"pending ({len(approvers)})")
if __name__ == "__main__":
main()