Skip to content

feat(ci): GitLab CI/CD pipeline for same-server deployment #370

feat(ci): GitLab CI/CD pipeline for same-server deployment

feat(ci): GitLab CI/CD pipeline for same-server deployment #370

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
JAVA_VERSION: '17'
NODE_VERSION: '22.x'
PNPM_VERSION: '10'
jobs:
# --- Changes detection ---
changes:
name: Detect Changes
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
backend: ${{ steps.filter.outputs.backend }}
console: ${{ steps.filter.outputs.console }}
management: ${{ steps.filter.outputs.management }}
docker: ${{ steps.filter.outputs.docker }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Detect changed paths
id: filter
uses: dorny/paths-filter@v4
with:
filters: |
backend:
- 'backend-spring/**'
- 'init-db/migrations/**'
- '.github/workflows/ci.yml'
console:
- 'console/**'
- 'shared/**'
- '.github/workflows/ci.yml'
management:
- 'management/**'
- 'shared/**'
- '.github/workflows/ci.yml'
docker:
- 'backend-spring/Dockerfile'
- 'console/Dockerfile'
- 'management/Dockerfile'
- 'docker-compose*.yml'
- '.dockerignore'
- 'console/nginx.conf'
- 'management/nginx.conf'
- '.github/workflows/ci.yml'
# --- Backend: Build ---
backend-build:
name: Backend Build
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'maven'
- name: Grant Maven wrapper execute permission
run: chmod +x backend-spring/mvnw
- name: Build with Maven
run: cd backend-spring && ./mvnw compile -B
# --- Backend: Test ---
backend-test:
name: Backend Test
needs: [changes, backend-build]
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
services:
mysql:
image: mysql:9.1
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ulticode
MYSQL_USER: ulticode
MYSQL_PASSWORD: ulticode
ports:
- 23306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis:7-alpine
ports:
- 26379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
DB_HOST: localhost
DB_PORT: 23306
DB_USER: ulticode
DB_PASSWORD: ulticode
DB_NAME: ulticode
REDIS_HOST: localhost
REDIS_PORT: 26379
JWT_SECRET: test-jwt-secret-key-for-ci-minimum-32-characters-long
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'maven'
- name: Grant Maven wrapper execute permission
run: chmod +x backend-spring/mvnw
- name: Run tests with CI profile
run: cd backend-spring && ./mvnw test -Dspring.profiles.active=ci -Dtest='!*IT' -B
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results-backend
path: backend-spring/target/surefire-reports/
retention-days: 7
# --- Backend: Test under feature flag matrix (ADR-005 §4 #4) ---
# 与 backend-test 同结构, 改 matrix 化: features=[off,on] 两个变体覆盖
# "全关" 与 "产品全开" 路径, 防 "默认 flag 路径 vs 全开/全关路径" 行为漂移.
# cutover flag 在所有变体都保持 false (走 cutover 守门, 不在 CI 强开).
# 第三个 default 路径由原 backend-test job 覆盖 (避免动 PR status check).
# R-R1 修复: 原 backend-test-features-{off,on} 2 job 复制 162 行 services+steps,
# 现 matrix 化只 1 job 90 行, 改 backend-test 模板不再漏改.
backend-test-features:
name: Backend Test (features ${{ matrix.features }})
needs: [changes, backend-build]
if: needs.changes.outputs.backend == 'true'
strategy:
fail-fast: false
matrix:
features: [off, on]
runs-on: ubuntu-latest
services:
mysql:
image: mysql:9.1
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ulticode
MYSQL_USER: ulticode
MYSQL_PASSWORD: ulticode
ports:
- 23306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis:7-alpine
ports:
- 26379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
DB_HOST: localhost
DB_PORT: 23306
DB_USER: ulticode
DB_PASSWORD: ulticode
DB_NAME: ulticode
REDIS_HOST: localhost
REDIS_PORT: 26379
JWT_SECRET: test-jwt-secret-key-for-ci-minimum-32-characters-long
# 5 cutover flag 全 false (matrix 无关, 走 cutover 守门)
USE_JUDGE_OUTBOX: 'false'
USE_GENERATION_FENCE: 'false'
JUDGE_QUEUE_USE_PORT: 'false'
JUDGE_QUEUE_ENVELOPE_VERSION: '1'
JUDGE_QUEUE_CUTOVER_AT: '1970-01-01T00:00:00'
USE_NOTIFICATION_INTENT: 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'maven'
- name: Grant Maven wrapper execute permission
run: chmod +x backend-spring/mvnw
# 5 个产品 flag 由 matrix.features 决定 on/off, 通过 env 注入到 step.
# Spring profile 同时追加 features-${{ matrix.features }}, 与 yml profile
# 形成双保险 (yml 提供默认值, env 覆盖兜底).
- name: Inject feature flag env vars from matrix
run: |
if [[ "${{ matrix.features }}" == "on" ]]; then
echo "USE_NEW_CONTEST_SYSTEM=true" >> "$GITHUB_ENV"
echo "REALTIME_RANKING_ENABLED=true" >> "$GITHUB_ENV"
echo "FIRST_SOLVE_NOTIFICATIONS_ENABLED=true" >> "$GITHUB_ENV"
echo "ANTICHEAT_ENABLED=true" >> "$GITHUB_ENV"
echo "CONTEST_ANALYTICS_ENABLED=true" >> "$GITHUB_ENV"
else
echo "USE_NEW_CONTEST_SYSTEM=false" >> "$GITHUB_ENV"
echo "REALTIME_RANKING_ENABLED=false" >> "$GITHUB_ENV"
echo "FIRST_SOLVE_NOTIFICATIONS_ENABLED=false" >> "$GITHUB_ENV"
echo "ANTICHEAT_ENABLED=false" >> "$GITHUB_ENV"
echo "CONTEST_ANALYTICS_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Run tests with features-${{ matrix.features }} profile
run: cd backend-spring && ./mvnw test -Dspring.profiles.active=ci,features-${{ matrix.features }} -Dtest='!*IT' -B
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results-backend-features-${{ matrix.features }}
path: backend-spring/target/surefire-reports/
retention-days: 7
# --- Backend: Validate Migrations ---
migrate-validate:
name: Validate Migrations
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
services:
mysql:
image: mysql:9.1
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ulticode_test
MYSQL_USER: ulticode
MYSQL_PASSWORD: ulticode
ports:
- 23306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
DB_HOST: localhost
DB_PORT: 23306
DB_USER: ulticode
DB_PASSWORD: ulticode
DB_NAME: ulticode_test
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Wait for MySQL
run: |
for i in $(seq 1 30); do
if mysqladmin ping -h localhost -P 23306 -u ulticode -pulticode 2>/dev/null; then
echo "MySQL is ready"
break
fi
echo "Waiting for MySQL... ($i/30)"
sleep 2
done
- name: Run and validate migrations
run: |
docker run --rm --network host \
-v "$PWD/init-db/migrations:/flyway/sql:ro" \
flyway/flyway:10.17.0 \
-url="jdbc:mysql://127.0.0.1:23306/ulticode?allowPublicKeyRetrieval=true&useSSL=false" \
-user=ulticode -password=ulticode -connectRetries=10 migrate
docker run --rm --network host \
-v "$PWD/init-db/migrations:/flyway/sql:ro" \
flyway/flyway:10.17.0 \
-url="jdbc:mysql://127.0.0.1:23306/ulticode?allowPublicKeyRetrieval=true&useSSL=false" \
-user=ulticode -password=ulticode validate
secret-scan:
name: Secret Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run Gitleaks
run: |
docker run --rm \
-v "$PWD:/repo:ro" \
zricethezav/gitleaks:v8.24.2 \
dir /repo --config=/repo/.gitleaks.toml --redact --no-banner
# --- Backend: Flyway Filename Lint ---
# ADR-005 §4 #5: DB 迁移文件名遵守 V{ts}__{description}.sql (CLAUDE.md 约定)
# 正则 ^V[0-9]{8}_?[0-9_]*__[A-Za-z0-9_]+\.sql$ 兼容两种时间戳写法
# (V20260604_110000 与 V20260604110000), 与现有 28 个文件全部合规.
flyway-filename-lint:
name: Flyway Filename Lint
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint migration filenames
run: |
set -e
PATTERN='^V[0-9]{8}_?[0-9_]*__[A-Za-z0-9_]+\.sql$'
cd init-db/migrations
fails=0
total=0
for f in V*.sql; do
[[ -e "$f" ]] || continue
total=$((total+1))
if [[ ! "$f" =~ $PATTERN ]]; then
echo "::error file=$f::must match $PATTERN (V<14-digit-ts>__<desc>.sql)"
fails=$((fails+1))
fi
done
if [[ $fails -gt 0 ]]; then
echo "::error::$fails of $total migration file(s) violate naming convention"
exit 1
fi
echo "✅ all $total migration filenames conform to V<14-digit-ts>__<desc>.sql"
# --- Frontend: Lint ---
frontend-lint:
name: Lint (${{ matrix.app }})
needs: changes
if: needs.changes.outputs.console == 'true' || needs.changes.outputs.management == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app: [console, management]
steps:
- name: Check if app changed
id: should-run
if: (matrix.app == 'console' && needs.changes.outputs.console == 'true') || (matrix.app == 'management' && needs.changes.outputs.management == 'true')
run: echo "run=true" >> $GITHUB_OUTPUT
- name: Checkout repository
if: steps.should-run.outputs.run == 'true'
uses: actions/checkout@v6
- name: Enable pnpm via corepack
if: steps.should-run.outputs.run == 'true'
run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate
- name: Setup Node.js
if: steps.should-run.outputs.run == 'true'
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
cache-dependency-path: ${{ matrix.app }}/pnpm-lock.yaml
- name: Install dependencies
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
env:
CI: "false"
GITHUB_ACTIONS: "false"
run: pnpm install
- name: Run lint
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
run: pnpm lint
# --- Frontend: Type Check ---
frontend-type-check:
name: Type Check (${{ matrix.app }})
needs: changes
if: needs.changes.outputs.console == 'true' || needs.changes.outputs.management == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app: [console, management]
steps:
- name: Check if app changed
id: should-run
if: (matrix.app == 'console' && needs.changes.outputs.console == 'true') || (matrix.app == 'management' && needs.changes.outputs.management == 'true')
run: echo "run=true" >> $GITHUB_OUTPUT
- name: Checkout repository
if: steps.should-run.outputs.run == 'true'
uses: actions/checkout@v6
- name: Enable pnpm via corepack
if: steps.should-run.outputs.run == 'true'
run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate
- name: Setup Node.js
if: steps.should-run.outputs.run == 'true'
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
cache-dependency-path: ${{ matrix.app }}/pnpm-lock.yaml
- name: Install dependencies
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
env:
CI: "false"
GITHUB_ACTIONS: "false"
run: pnpm install
- name: Run type-check
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
run: pnpm type-check
# --- Frontend: Test ---
frontend-test:
name: Test (${{ matrix.app }})
needs: changes
if: needs.changes.outputs.console == 'true' || needs.changes.outputs.management == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app: [console, management]
steps:
- name: Check if app changed
id: should-run
if: (matrix.app == 'console' && needs.changes.outputs.console == 'true') || (matrix.app == 'management' && needs.changes.outputs.management == 'true')
run: echo "run=true" >> $GITHUB_OUTPUT
- name: Checkout repository
if: steps.should-run.outputs.run == 'true'
uses: actions/checkout@v6
- name: Enable pnpm via corepack
if: steps.should-run.outputs.run == 'true'
run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate
- name: Setup Node.js
if: steps.should-run.outputs.run == 'true'
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
cache-dependency-path: ${{ matrix.app }}/pnpm-lock.yaml
- name: Install dependencies
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
env:
CI: "false"
GITHUB_ACTIONS: "false"
run: pnpm install
- name: Run tests
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
run: pnpm test
- name: Audit production dependencies
if: steps.should-run.outputs.run == 'true'
working-directory: ${{ matrix.app }}
run: pnpm audit --prod --audit-level high
shared-auth-test:
name: Test shared auth core
needs: changes
if: needs.changes.outputs.console == 'true' || needs.changes.outputs.management == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Enable pnpm via corepack
run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
cache-dependency-path: shared/auth-core/pnpm-lock.yaml
- name: Install dependencies
working-directory: shared/auth-core
run: pnpm install --frozen-lockfile
- name: Test and type-check
working-directory: shared/auth-core
run: pnpm test && pnpm type-check
# --- Frontend: i18n Check (management only) ---
frontend-i18n-check:
name: i18n Check (management)
needs: changes
if: needs.changes.outputs.management == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Enable pnpm via corepack
run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
cache-dependency-path: management/pnpm-lock.yaml
- name: Install dependencies
working-directory: management
env:
CI: "false"
GITHUB_ACTIONS: "false"
run: pnpm install
- name: Run i18n completeness check
working-directory: management
run: pnpm check:i18n --json
- name: Run i18n tests
working-directory: management
run: pnpm vitest run -- src/i18n/__tests__/
# --- Docker Build Verification ---
docker-verify:
name: Docker Build (${{ matrix.service.name }})
needs: changes
if: needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service:
- name: backend
dockerfile: ./backend-spring/Dockerfile
- name: console
dockerfile: ./console/Dockerfile
- name: management
dockerfile: ./management/Dockerfile
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image (no push)
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.service.dockerfile }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max