generated from ttipo/project-base
3029bb20f2
운영 메일 서버를 NCP 에 따로 두고, 포털의 TLS 는 기존 서버의 Nginx Proxy Manager(다른 사이트들과 80 · 443 을 함께 쓴다)가
같은 VPC 사설망으로 넘기기로 했다. 메일 엔진이 그 서버의 443 을 직접 써야 해서(ACME · MTA-STS) 프록시를 같은 호스트에 둘 수 없다.
- docker-compose.remote-proxy.yml: frontend 를 proxy 네트워크에서 빼고 8080 을 FRONTEND_PRIVATE_BIND(사설 IPv4:포트)에만 게시한다.
compose 의 ${…:?} 는 빈 값만 막아 포트뿐인 값이면 모든 주소에 열리므로, 한 번짜리 frontend-bind-check 가 값을 먼저 보고
아니면 frontend 를 띄우지 않는다(네트워크 없음 · 비-root · DB 이미지의 셸).
- .env.production 의 COMPOSE_FILE 로 켠다 — 운영 명령(--env-file .env.production)을 그대로 쓰고 -f 를 빠뜨려 기본 구성으로 도는 일이 없게.
- TRUST_PROXY 는 <EDGE_NETWORK_SUBNET>,<프록시 서버 사설 IP> 주소 목록, NPM 은 v2.16.0 소스로 기본값을 확인해 고급 칸 세 줄
(읽기 150s · 접근 로그 끔 · 사설 대역의 X-Real-IP 믿지 않기)과 Block Common Exploits 끄기(메일 검색이 403)를 적었다.
- NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 게시 주소로는 바깥을 막지 못한다 — 8080 은 ACG 가 프록시 서버 사설 IP 에서만 받는다.
- CI infra 잡이 이 조합의 config 와 frontend-bind-check 의 받을 값 · 거절할 값을 본다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
11 KiB
YAML
236 lines
11 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
|
|
# 같은 브랜치에서 새 푸시가 오면 이전 실행을 취소한다
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
# lock(package-lock.json)을 쓰고 검사하는 npm 버전 — backend·frontend Dockerfile 의 NPM_VERSION, docker 스킬 문서와 같은 값.
|
|
# setup-node 의 Node 에 딸린 npm(Node 24 는 11.19 등 — 패치마다 바뀐다)은 lock 의 중첩 항목을 다르게 정리하므로 그대로 쓰지 않는다.
|
|
# Node 는 24 LTS 패치까지 고정 — backend·frontend Dockerfile 의 ARG NODE_VERSION 과 같은 값 (세 잡이 이 값 하나를 쓴다).
|
|
# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)은 docker 스킬 문서 "Node 버전".
|
|
env:
|
|
NPM_VERSION: '11.6.2'
|
|
NODE_VERSION: '24.22.1'
|
|
|
|
jobs:
|
|
backend:
|
|
name: Backend (lint · type-check · test · e2e · build)
|
|
runs-on: ubuntu-latest
|
|
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
|
timeout-minutes: 20
|
|
defaults:
|
|
run:
|
|
working-directory: backend
|
|
# e2e 는 응답 계약을 실제 HTTP 로 검증하므로 DB 가 필요하다.
|
|
#
|
|
# ⚠️ 호스트 포트 매핑(ports)을 두지 않는다.
|
|
# Gitea Actions 러너는 잡을 **컨테이너로** 실행하고 서비스와 같은 네트워크에 붙여준다.
|
|
# 따라서 서비스 이름(postgres)으로 바로 접근할 수 있고, 호스트 포트는 필요 없다.
|
|
# 오히려 개발 DB 가 이미 5432 를 쓰고 있으면 충돌해 잡 전체가 시작조차 못 한다.
|
|
# (GitHub Actions 는 잡이 VM 위에서 직접 돌아 localhost + ports 를 쓰는 점이 다르다)
|
|
services:
|
|
postgres:
|
|
image: postgres:15-alpine
|
|
env:
|
|
POSTGRES_USER: ci_user
|
|
POSTGRES_PASSWORD: ci_password
|
|
POSTGRES_DB: ci_db
|
|
options: >-
|
|
--health-cmd "pg_isready -U ci_user -d ci_db"
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 5
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: npm
|
|
cache-dependency-path: backend/package-lock.json
|
|
|
|
- name: npm 버전 고정
|
|
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
|
|
|
|
- run: npm ci
|
|
|
|
# Warning 0 (.claude/rules/checklist.md)
|
|
- name: Lint
|
|
run: npm run lint
|
|
|
|
# tsc --noEmit -p tsconfig.json — 빌드(tsconfig.build.json)가 빼는 spec·test 파일까지 타입 검사한다
|
|
- name: Type check
|
|
run: npm run type-check
|
|
|
|
- name: Test
|
|
run: npm run test
|
|
|
|
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
|
|
- name: E2E test
|
|
run: npm run test:e2e
|
|
env:
|
|
NODE_ENV: test
|
|
# 서비스 컨테이너의 네트워크 별칭 (localhost 는 잡 컨테이너 자신을 가리킨다)
|
|
DB_HOST: postgres
|
|
DB_PORT: 5432
|
|
DB_USER: ci_user
|
|
DB_PASSWORD: ci_password
|
|
DB_NAME: ci_db
|
|
|
|
- name: Build
|
|
run: npm run build
|
|
|
|
frontend:
|
|
name: Frontend (lint · type-check · test · build)
|
|
runs-on: ubuntu-latest
|
|
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
|
timeout-minutes: 15
|
|
defaults:
|
|
run:
|
|
working-directory: frontend
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: npm
|
|
cache-dependency-path: frontend/package-lock.json
|
|
|
|
- name: npm 버전 고정
|
|
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
|
|
|
|
- run: npm ci
|
|
|
|
- name: Lint
|
|
run: npm run lint
|
|
|
|
- name: Type check
|
|
run: npm run type-check
|
|
|
|
- name: Test
|
|
run: npm run test:unit -- --run
|
|
|
|
# 운영 이미지와 같은 vite 빌드 (타입 검사는 위에서 했으므로 build-only)
|
|
- name: Build
|
|
run: npm run build-only
|
|
|
|
infra:
|
|
name: Infra (compose · Dockerfile · nginx)
|
|
runs-on: ubuntu-latest
|
|
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
|
timeout-minutes: 10
|
|
# 잡 컨테이너 안에서 docker 를 쓴다 — act_runner 기본 설정(docker_host 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고,
|
|
# 잡 이미지(gitea/runner-images:ubuntu-latest)에 docker CLI·compose 가 들어 있다 (docker 스킬 "CI 러너").
|
|
# 빌드 컨텍스트는 API 로 올라가므로 잡 컨테이너 안의 체크아웃으로 빌드할 수 있다 (-v 바인드 마운트는 호스트 경로라 쓰지 않는다)
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: compose 문법·변수 치환 (예시 env)
|
|
run: |
|
|
# env_file 대상(backend/.env.*)은 커밋되지 않는다 — 빈 파일로 자리만 채운다
|
|
touch backend/.env.development backend/.env.production
|
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q
|
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q
|
|
docker compose -f docker-compose.yml --env-file .env.production.example config -q
|
|
# 리버스 프록시가 다른 서버에 있을 때 (docker-compose.remote-proxy.yml — mail/README.md). 게시 주소는 이 단계에서만 준다
|
|
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q
|
|
# proxy 네트워크가 빠져야 한다 — 남으면 그 서버에 없는 external 네트워크를 찾다 up 이 멈춘다
|
|
# (grep -q 는 쓰지 않는다 — 먼저 끝나면 pipefail 셸에서 compose 의 SIGPIPE 가 결과를 뒤집는다)
|
|
if FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config --networks | grep -x proxy >/dev/null; then
|
|
echo "::error::docker-compose.remote-proxy.yml 이 proxy 네트워크를 빼지 못했습니다"; exit 1
|
|
fi
|
|
# 게시 주소가 없으면 멈춰야 한다 (예시 env 의 FRONTEND_PRIVATE_BIND 는 비어 있다 — 빈 값이면 모든 주소에 열린다)
|
|
if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then
|
|
echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1
|
|
fi
|
|
|
|
# docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다
|
|
# (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다
|
|
- name: frontend 게시 주소 확인 (리버스 프록시가 다른 서버에 있을 때)
|
|
run: |
|
|
touch backend/.env.production
|
|
check() {
|
|
FRONTEND_PRIVATE_BIND="$1" docker compose -p "ci-bind-${{ github.run_id }}" \
|
|
-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example \
|
|
run --rm --no-deps -T frontend-bind-check
|
|
}
|
|
for value in 10.0.1.12:8080 172.31.255.254:65535 192.168.0.10:18080; do
|
|
check "$value" || { echo "::error::FRONTEND_PRIVATE_BIND=$value 를 거부했습니다 (받아야 한다)"; exit 1; }
|
|
done
|
|
for value in 8080 :8080 0.0.0.0:8080 127.0.0.1:8080 203.0.113.5:8080 172.32.0.1:8080 '[::]:8080' 10.0.1.12:0 10.0.1.12:08080; do
|
|
if check "$value" 2>/dev/null; then
|
|
echo "::error::FRONTEND_PRIVATE_BIND=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
done
|
|
|
|
- name: Dockerfile 검사
|
|
run: |
|
|
docker build --check backend
|
|
docker build --check frontend
|
|
|
|
# 운영 이미지의 nginx 단계만 만든다 (화면 빌드 없음 — frontend/Dockerfile 의 nginx-config)
|
|
- name: nginx 설정 검사
|
|
run: |
|
|
tag="ci-nginx-config:${{ github.run_id }}"
|
|
docker build --target nginx-config -t "$tag" frontend
|
|
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
|
|
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소)
|
|
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
|
|
echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then
|
|
echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
|
|
- name: 정리
|
|
if: always()
|
|
run: |
|
|
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
|
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
|
|
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
|
|
|
|
error-codes-sync:
|
|
name: 에러 코드 SSOT 동기화 검사
|
|
runs-on: ubuntu-latest
|
|
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
|
timeout-minutes: 10
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: npm
|
|
cache-dependency-path: backend/package-lock.json
|
|
|
|
- name: npm 버전 고정
|
|
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
|
|
|
|
- name: Install backend deps
|
|
working-directory: backend
|
|
run: npm ci
|
|
|
|
# 레지스트리만 고치고 sync 를 안 돌린 경우를 잡아낸다.
|
|
# 생성 파일이 최신이면 diff 가 비어 있어야 한다.
|
|
- name: Regenerate client error lexicons
|
|
working-directory: backend
|
|
run: npm run sync:error-codes
|
|
|
|
- name: Fail if generated files are stale
|
|
run: |
|
|
if ! git diff --exit-code -- \
|
|
frontend/src/shared/constants/error-codes.generated.ts \
|
|
flutter/lib/core/api/error_lexicon.g.dart; then
|
|
echo ""
|
|
echo "::error::에러 코드 생성 파일이 레지스트리와 어긋났습니다."
|
|
echo "backend 에서 'npm run sync:error-codes' 를 실행하고 생성 파일을 함께 커밋하세요."
|
|
exit 1
|
|
fi
|