Files
arasystem/.github/workflows/ci.yml
T
ttipo 3029bb20f2 feat(docker): 리버스 프록시가 다른 서버에 있을 때 — frontend 를 사설 IP 에만 게시하는 선택 파일
운영 메일 서버를 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>
2026-10-30 14:07:15 +09:00

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