generated from ttipo/project-base
9466a85daa
엔진의 DNS 조회(DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버의 리졸버로 푼다. Spamhaus 는 공용 DNS 를 거친 조회를 거부하고, DNSSEC 을 검증해야 엔진의 DANE 이 켜지기 때문이다. - mail-resolver: unbound 1.26.2(digest 고정 · 비-root · cap_drop ALL). mail 과만 닿는 internal 네트워크 mail-dns 와 바깥 길 mail-dns-egress. 사설 주소의 답 · 질의를 막는다. - setup: 바꾸기 전에 리졸버 네트워크 · 주소 · DNSSEC 을 확인한다(UDP 는 루트 SOA, TCP 는 루트 DNSKEY — UDP DNSKEY 는 루트 키 교체 기간에 잘려 오탐이 난다). 엔진 DNS 는 운영 Custom, 개발 System. Spamhaus 키가 있으면 시험 항목을 확인한 뒤 DQS 로 켜고 없으면 두 항목을 끈다. 키는 모든 출력에서 가린다. - mail 은 리졸버를 기다리되(required: false) 리졸버 오류 하나로 메일 전체가 서지 않게 한다. - README: 리졸버와 스팸 차단 목록, 운영 배포 차례, 리졸버 장애 때(리로드 거절 · restart 비상 길), DNSSEC 이 깨진 도메인의 예외. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
12 KiB
YAML
247 lines
12 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
|
|
|
|
# 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf.
|
|
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다)
|
|
- name: unbound 설정 검사
|
|
run: |
|
|
touch backend/.env.production
|
|
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | grep unbound)
|
|
tag="ci-unbound-config:${{ github.run_id }}"
|
|
printf 'FROM %s\nCOPY unbound.conf /etc/unbound/unbound.conf\n' "$image" | docker build -t "$tag" -f- mail/resolver
|
|
docker run --rm --user 101:102 --entrypoint unbound-checkconf "$tag"
|
|
|
|
- name: 정리
|
|
if: always()
|
|
run: |
|
|
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
|
docker image rm -f "ci-unbound-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
|