generated from ttipo/project-base
93147b88b7
- ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용), freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav. clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README). 검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다. - nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다). 조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k, limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200. 받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다). 접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다. - 파일은 named volume <PROJECT_NAME>_files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다. - CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부. - mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
22 KiB
YAML
368 lines
22 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
|
|
# 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf)
|
|
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
|
|
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다
|
|
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t
|
|
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null)
|
|
for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \
|
|
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do
|
|
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
|
|
done
|
|
# 잘못된 값은 기동을 멈춰야 한다 (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
|
|
for value in https://files.ci.test/ https://files.ci.test/s files.ci.test 'https://files.ci.test;return 200' https://files.ci.test.; do
|
|
if docker run --rm -e PROJECT_NAME=ci -e "SHARE_PUBLIC_URL=$value" "$tag" nginx -t; then
|
|
echo "::error::SHARE_PUBLIC_URL=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
done
|
|
|
|
# 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 ·
|
|
# X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말).
|
|
# 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로,
|
|
# ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다
|
|
- name: 대용량 받기 경로 시험
|
|
run: |
|
|
base="ci-nginx-config:${{ github.run_id }}"
|
|
tag="ci-nginx-paths:${{ github.run_id }}"
|
|
name="ci-nginx-paths-${{ github.run_id }}"
|
|
ctx=$(mktemp -d)
|
|
cat > "$ctx/paths.sh" <<'EOF'
|
|
#!/bin/sh
|
|
P=http://127.0.0.1:8080
|
|
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
|
F=11111111-2222-4333-8444-555555555555
|
|
fails=0
|
|
# host path → "상태 xab|-"
|
|
probe() {
|
|
timeout 10 wget -S -O /dev/null --header "Host: $1" "$P$2" 2>&1 | awk '
|
|
/^ HTTP\// { s = $2 }
|
|
/ error: HTTP\// { for (i = 1; i <= NF; i++) if ($i ~ /^HTTP\//) s = $(i + 1) }
|
|
tolower($0) ~ /^ x-accel-buffering: no/ { x = "xab" }
|
|
END { print (s ? s : "none"), (x ? x : "-") }'
|
|
}
|
|
# host method path [머리글] → "상태 Content-Type code" — nginx 가 직접 끝낸 응답의 JSON
|
|
raw() {
|
|
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; } \
|
|
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
|
|
NR == 1 { s = $2 }
|
|
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
|
|
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
|
|
END { print s, (t ? t : "-"), (c ? c : "-") }'
|
|
}
|
|
check() { if [ "$got" = "$1" ]; then echo "ok $2 → $got"; else echo "FAIL $2 → $got (기대 $1)"; fails=$((fails + 1)); fi; }
|
|
expect() { got=$(probe "$2" "$3"); check "$1" "$2$3"; }
|
|
expect_raw() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
|
hold() { n=0; while [ "$n" -lt "$3" ]; do wget -q -O /dev/null --header "Host: $1" "$P$2?hold=1" >/dev/null 2>&1 & n=$((n + 1)); done; sleep 3; }
|
|
release() { killall wget >/dev/null 2>&1; sleep 2; }
|
|
PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F"
|
|
INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F"
|
|
|
|
echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도"
|
|
for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done
|
|
for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done
|
|
expect "200 -" files.ci.test "/api/Public-Links/$T"
|
|
expect "200 -" portal.ci.test "/api/Messages/M1"
|
|
expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1"
|
|
expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1"
|
|
echo "== nginx 가 끝내는 /api 응답은 JSON"
|
|
expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me
|
|
expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000"
|
|
expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F"
|
|
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30. 붙잡아 둔 뒤 변형으로 하나 더"
|
|
hold files.ci.test "/api/public-links/$T/files/$F" 10
|
|
for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done
|
|
expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F"
|
|
expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F"
|
|
release
|
|
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
|
|
for p in $INAPP; do expect "429 -" portal.ci.test "$p"; done
|
|
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
|
|
release
|
|
echo "fails=$fails"
|
|
[ "$fails" -eq 0 ]
|
|
EOF
|
|
{ printf 'FROM %s\n' "$base"; cat <<'EOF'; } > "$ctx/Dockerfile"
|
|
RUN mkdir -p /tmp/stub && head -c 10485760 /dev/zero > /tmp/stub/big && echo ok > /tmp/stub/small \
|
|
&& pad=$(head -c 3000 /dev/zero | tr '\0' a) \
|
|
&& printf 'map $arg_hold $stub_rate { 1 1k; default 0; }\nmap $arg_hold $stub_file { 1 /big; default /small; }\nmap $arg_pad $stub_pad { 1 "%s"; default ""; }\nserver {\n listen 127.0.0.1:3000;\n access_log off;\n location / {\n root /tmp/stub;\n limit_rate $stub_rate;\n add_header X-Accel-Buffering no;\n add_header X-Pad1 $stub_pad;\n add_header X-Pad2 $stub_pad;\n try_files $stub_file =404;\n }\n}\n' "$pad" > /etc/nginx/conf.d/zz-stub.conf
|
|
COPY --chmod=755 paths.sh /usr/local/bin/paths.sh
|
|
EOF
|
|
docker build -t "$tag" "$ctx"
|
|
docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \
|
|
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag"
|
|
for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done
|
|
docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; }
|
|
|
|
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
|
|
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
|
|
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
|
|
# AlertExceedsMax · AlertEncrypted 가 꺼지면 한도를 넘거나 암호가 걸린 파일이 "깨끗함(OK)"으로 온다 (mail/README.md "악성 검사 (ClamAV)")
|
|
- name: ClamAV 설정 검사
|
|
run: |
|
|
touch backend/.env.production
|
|
# clamav · clamav-updater 가 같은 이미지라 두 줄 — 첫 줄을 awk 로 고른다(끝까지 읽는다). grep -m1 · head 는 먼저 끝나 pipefail 셸에서
|
|
# compose 의 SIGPIPE 가 결과를 뒤집을 수 있다 (위 compose 단계와 같은 까닭)
|
|
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | awk '/^clamav\/clamav:/ && !seen { print; seen = 1 }')
|
|
[ -n "$image" ] || { echo "::error::docker-compose.yml 에서 clamav/clamav 이미지를 찾지 못했습니다"; exit 1; }
|
|
tag="ci-clamav-config:${{ github.run_id }}"
|
|
printf 'FROM %s\nCOPY clamd.conf freshclam.conf /etc/clamav/\n' "$image" | docker build -t "$tag" -f- mail/clamav
|
|
conf=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav -n 2>&1)
|
|
printf '%s\n' "$conf"
|
|
if printf '%s\n' "$conf" | grep -E '^(ERROR|WARNING)' >/dev/null; then
|
|
echo "::error::clamconf 가 mail/clamav 의 설정을 읽지 못했습니다 (위 ERROR 줄)"; exit 1
|
|
fi
|
|
for want in 'StreamMaxLength = "2146435072"' 'MaxFileSize = "2146435072"' 'MaxScanSize = "4292870144"' 'MaxScanTime = "300000"' 'AlertExceedsMax = "yes"' 'AlertEncrypted = "yes"' 'ConcurrentDatabaseReload disabled' 'TCPSocket = "3310"'; do
|
|
printf '%s\n' "$conf" | grep -F "$want" >/dev/null || { echo "::error::clamd.conf 에 '$want' 가 없습니다 (mail/clamav/clamd.conf)"; exit 1; }
|
|
done
|
|
# -n 은 기본값과 같은 값을 숨긴다 — 기본값과 같지만 판정에 기대는 값(서명 다시 읽기 10분 · 진짜 서명이 휴리스틱보다 먼저)은
|
|
# -n 없이 clamd.conf 칸에서 본다 (기본값이 바뀐 판으로 올려도 알아차리게)
|
|
clamd=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav 2>&1 | awk '/^Config file: /{ on = ($3 == "clamd.conf") } on')
|
|
for want in 'SelfCheck = "600"' 'HeuristicScanPrecedence disabled'; do
|
|
printf '%s\n' "$clamd" | grep -F -x "$want" >/dev/null || { echo "::error::clamd.conf 의 값이 '$want' 가 아닙니다 (mail/clamav/clamd.conf)"; exit 1; }
|
|
done
|
|
|
|
# 메일 전용 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 rm -f "ci-nginx-paths-${{ github.run_id }}" || true
|
|
docker image rm -f "ci-nginx-paths:${{ github.run_id }}" || true
|
|
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
|
docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true
|
|
docker image rm -f "ci-clamav-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
|