generated from ttipo/project-base
7c70862647
APK 받기가 일반 /api 로 가면 X-Accel-Buffering 이 외부 프록시로 넘어가지 않아 받는 사람마다 임시 파일이 쌓이고, 버퍼링을 끈 받기에 둘 동시 연결 상한도 없다(리뷰). 앱 안 대용량 받기와 같은 location(대소문자를 가리지 않는 정규식 · 같은 그릇)을 두고, CI 의 받기 경로 시험에 변형 · 정보 경로 · 그릇 공유를 더했다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
40 KiB
YAML
559 lines
40 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
|
|
# 로그 보존(13단계) — 모든 서비스에 logging 이 있어야 한다(없으면 도커 기본 — 회전 없이 디스크를 채운다).
|
|
# 서비스는 두 칸, 그 키는 네 칸 들여 쓴다(compose config 의 출력 꼴)
|
|
# 리버스 프록시가 다른 서버일 때의 조합(remote-proxy — frontend-bind-check 가 더해진다)도 센다. 게시 주소는 이 확인에서만 준다
|
|
for args in "-f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile mail --profile redis --profile frontend --profile clamav" \
|
|
"-f docker-compose.yml --env-file .env.production.example --profile mail --profile redis" \
|
|
"-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example --profile mail --profile redis"; do
|
|
# shellcheck disable=SC2086
|
|
services=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config --services | wc -l)
|
|
# shellcheck disable=SC2086
|
|
logged=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config | awk '/^ logging:/ { c++ } END { print c + 0 }')
|
|
[ "$services" -eq "$logged" ] || { echo "::error::logging 이 없는 서비스가 있습니다 ($args — 서비스 $services · logging $logged)"; exit 1; }
|
|
done
|
|
# DB 이미지는 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정")
|
|
for envf in .env.development.example .env.production.example; do
|
|
grep -Eq '^DB_IMAGE=postgres:[0-9]+\.[0-9]+-alpine[0-9.]+@sha256:[0-9a-f]{64}$' "$envf" || { echo "::error::$envf 의 DB_IMAGE 가 판 · digest 까지 고정되지 않았습니다"; exit 1; }
|
|
done
|
|
|
|
# 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). 두 꼴 모두 문법을 본다.
|
|
# 이 꼴은 메일 프로그램 자동 설정(13단계 — autoconfig. · autodiscover.<MAIL_DOMAIN>)도 켠다
|
|
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.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 -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.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;' \
|
|
'server_name autoconfig.ci.test autodiscover.ci.test;' '<hostname>mx1.ci.test</hostname>' '<Server>mx1.ci.test</Server><Port>993</Port>'; do
|
|
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
|
|
done
|
|
# 메일 값이 없으면 자동 설정은 꺼진다 — 어느 요청에도 맞지 않는 이름
|
|
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null | grep -F 'server_name autoconfig.invalid;' >/dev/null \
|
|
|| { echo "::error::MAIL_DOMAIN 없이 자동 설정 server 가 꺼지지 않았습니다"; exit 1; }
|
|
# 자동 설정의 값은 server_name · 응답 XML 에 그대로 들어간다 — 호스트 이름 꼴이 아니면 · 포털 · 공개 호스트와 겹치면 기동을 멈춰야 한다
|
|
for value in 'ci.test;return 200' 'ci..test' '-ci.test' 'ci.test.' 'citest' 'ci test' 'ci.test/x'; do
|
|
if docker run --rm -e PROJECT_NAME=ci -e "MAIL_DOMAIN=$value" -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t >/dev/null 2>&1; then
|
|
echo "::error::MAIL_DOMAIN=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
done
|
|
for value in 'mx1.ci.test<x>' 'mx1.ci.test:993' 'mx1'; do
|
|
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e "MAIL_HOSTNAME=$value" "$tag" nginx -t >/dev/null 2>&1; then
|
|
echo "::error::MAIL_HOSTNAME=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
done
|
|
# 끝의 / · 빈칸 · 여러 출처도 같게 본다 (공개 호스트 판정과 같은 읽기)
|
|
for value in 'https://autoconfig.ci.test' 'https://autoconfig.ci.test/' 'https://portal.ci.test, https://AUTODISCOVER.ci.test/'; do
|
|
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test -e "CORS_ORIGIN=$value" "$tag" nginx -t >/dev/null 2>&1; then
|
|
echo "::error::포털(CORS_ORIGIN=$value)과 겹치는 자동 설정 호스트를 받아들였습니다 (거부해야 한다)"; exit 1
|
|
fi
|
|
done
|
|
# 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다
|
|
# (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if)
|
|
plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null)
|
|
for form in plain rendered; do
|
|
if [ "$form" = plain ]; then conf=$plain; else conf=$rendered; fi
|
|
n=$(printf '%s\n' "$conf" | awk '$1 == "include" && $2 == "/etc/nginx/snippets/internal-api-closed.conf;" { c++ } END { print c + 0 }')
|
|
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
|
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
|
|
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
|
# 앱 안 받기 · 휴대폰 앱 받기(2026-11-08)가 같은 그릇(download_app_*)을 쓴다 — 포털의 두 location 에 하나씩, 두 꼴 모두 둘이다
|
|
for want in 'download_app_ip 30;' 'download_app_total 200;'; do
|
|
n=$(printf '%s\n' "$conf" | awk -v want="$want" '$1 == "limit_conn" && ($2 " " $3) == want { c++ } END { print c + 0 }')
|
|
[ "$n" -eq 2 ] || { echo "::error::limit_conn $want 이 든 location 이 둘(앱 안 받기 · 휴대폰 앱 받기)이 아닙니다 ($form: $n)"; exit 1; }
|
|
done
|
|
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 을 가른다.
|
|
# 휴대폰 앱(APK) 받기(/api/app-releases/…/file — 2026-11-08)도 본다: 같은 변형이 받기 location 을 타고 앱 안 받기와 그릇(주소마다 30)을 함께 쓰는지,
|
|
# 공개 호스트는 404 로 닫는지, 정보 경로(…/latest — JSON)는 받기 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"
|
|
# 휴대폰 앱 받기 — 끝의 / · %66(f) 꼴도. 정보 경로는 받기 location 밖이다
|
|
APK="/api/app-releases/android/latest/file /api/App-Releases/Android/Latest/FILE /api/APP-RELEASES/android/latest/File /api//app-releases/android/latest/file /api/app-releases/android%2Flatest/file /api/app-releases/android/latest/file/ /api/app-releases/android/latest/%66ile"
|
|
INFO="/api/app-releases/android/latest /api/App-Releases/Android/Latest /api/app-releases/android/latest/"
|
|
|
|
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
|
|
echo "== 휴대폰 앱 받기도 받기 location 을 탄다 · 공개 호스트는 닫는다 · 정보 경로는 /api 그대로"
|
|
for p in $APK; do expect "200 xab" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
|
|
for p in $INFO; do expect "200 -" portal.ci.test "$p"; expect "404 -" files.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"
|
|
expect "200 xab" portal.ci.test "/api/App-Releases/android/latest/file?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"
|
|
expect_raw "404 application/json RES_001" files.ci.test GET /api/app-releases/android/latest/file
|
|
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"
|
|
expect "200 xab" portal.ci.test "/api/app-releases/android/latest/file"
|
|
release
|
|
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
|
|
for p in $INAPP $APK; do expect "429 -" portal.ci.test "$p"; done
|
|
expect_raw "429 application/json REQ_001" portal.ci.test GET "/api/App-Releases/android/latest/FILE"
|
|
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
|
|
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
|
|
release
|
|
hold portal.ci.test "/api/app-releases/android/latest/file" 30
|
|
for p in $APK $INAPP; do expect "429 -" portal.ci.test "$p"; done
|
|
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
|
|
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 -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.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; }
|
|
|
|
# nginx 가 닫는 경로를 두 server 에서 실제 요청으로 본다 (12단계 — 위 단계의 컨테이너를 그대로 쓴다). 가짜 백엔드는 무엇에든
|
|
# 200(application/octet-stream)이라, nginx 가 직접 끝낸 JSON(code)이면 백엔드에 넘기지 않은 것이다.
|
|
# - 엔진만 부르는 /api/internal/(엔진 웹훅, docs/mail/api-contract.md 5절) → 404 RES_001 — 대소문자 · 겹 / · %2F · %XX(글자를 퍼센트로) · %5C · .. ·
|
|
# absolute-form · 끝 / 없음 · 있음. 1m 넘는 본문은 413 FILE_002
|
|
# - 원문 대상의 날 \(경로) · # · 0x80 이상 바이트(0xA0) → 400 VAL_001 — /api/internal · /api/health · 공개 API · 화면 경로(server 단계 $request_target_rejected).
|
|
# nginx 는 그 꼴을 닫는 정규식에 맞추지 못하는데 백엔드의 Express(parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받았다(2026-10-31 리뷰). 쿼리의 \ 는 받는다
|
|
# - %5C 꼴의 /api/health · 공개 API → 404 RES_001 (닫는 정규식의 [/\x5c])
|
|
- name: 닫는 경로 시험
|
|
run: |
|
|
name="ci-nginx-paths-${{ github.run_id }}"
|
|
docker exec -i "$name" sh -s <<'EOF'
|
|
fails=0
|
|
# host method target [머리글] → "상태 Content-Type code". WAIT 이 있으면 표준 입력을 1초 열어 둔다 — busybox nc 는 표준 입력이 끝나면 닫아
|
|
# 백엔드 · 화면으로 간(늦게 오는) 응답을 받지 못한다
|
|
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'; [ -n "$WAIT" ] && sleep 1; } \
|
|
| 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 : "-") }'
|
|
}
|
|
# 찍을 때 0x80 이상 바이트는 ~ 로
|
|
check() {
|
|
label=$(printf '%s' "$2" | tr '\200-\377' '~')
|
|
if [ "$got" = "$1" ]; then echo "ok $label → $got"; else echo "FAIL $label → $got (기대 $1)"; fails=$((fails + 1)); fi
|
|
}
|
|
expect() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
|
expect_wait() { got=$(WAIT=1 raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
|
BS=$(printf '\')
|
|
NB=$(printf '\240')
|
|
E=mail-engine-events
|
|
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
|
F=11111111-2222-4333-8444-555555555555
|
|
for host in portal.ci.test files.ci.test; do
|
|
echo "== $host — /api/internal 은 404"
|
|
for path in "/api/internal/${E}" "/api/INTERNAL/${E}" "/api/Internal/Mail-Engine-Events" "/api//internal/${E}" "/api/internal//${E}" \
|
|
"/api%2Finternal/${E}" "/api/%69nternal/${E}" "/api/%49NTERNAL/${E}" "/api/internal%5C${E}" "/api/internal%5c${E}" \
|
|
"/api/x/../internal/${E}" "/api/./internal/${E}" "http://${host}/api/internal/${E}" /api/internal /api/internal/ /api/Internal; do
|
|
expect "404 application/json RES_001" "$host" POST "$path"
|
|
done
|
|
expect "404 application/json RES_001" "$host" GET "/api/internal/${E}"
|
|
expect "413 application/json FILE_002" "$host" POST "/api/internal/${E}" "Content-Length: 2000000"
|
|
echo "== $host — 원문 대상의 날 \ · # · 0xA0 은 400"
|
|
for path in "/api/internal${BS}${E}" "/api/internal${BS}${E}#x" "/api/internal${BS}${E}${NB}" "/api${BS}internal/${E}" "/api/internal/${E}#x" "/api/internal/${E}${NB}" \
|
|
"/api/health${BS}" "/api/health${BS}#a" "/api/health#a" "/api/health${NB}" \
|
|
"/api/public-links${BS}${T}${BS}files${BS}${F}" "/api/public-links${BS}x#a" "/api/public-links/${T}#x" "/api/public-links/${T}${NB}"; do
|
|
expect "400 application/json VAL_001" "$host" POST "$path"
|
|
done
|
|
expect "404 application/json RES_001" "$host" GET "/api/health%5C"
|
|
done
|
|
echo "== 포털 — 공개 API 닫힘(%5C) · 화면 경로의 날 \ 은 400 · 쿼리의 \ 는 받는다 · 경계"
|
|
expect "404 application/json RES_001" portal.ci.test GET "/api/public-links%5C${T}"
|
|
expect "400 application/json VAL_001" portal.ci.test GET "/mail${BS}inbox"
|
|
expect_wait "200 text/html -" portal.ci.test GET "/mail/search?q=a${BS}b"
|
|
expect_wait "200 application/octet-stream -" portal.ci.test GET "/api/messages?q=a${BS}b"
|
|
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internals
|
|
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internal-x/y
|
|
expect_wait "200 text/html -" portal.ci.test GET "/API/internal/${E}"
|
|
echo "== 공개 호스트 — 공개 API 의 %5C 꼴은 404 · 화면 /s 의 날 \ 은 400"
|
|
expect "404 application/json RES_001" files.ci.test GET "/api/public-links%5C${T}"
|
|
expect "400 application/json VAL_001" files.ci.test GET "/s${BS}${T}"
|
|
echo "fails=$fails"
|
|
[ "$fails" -eq 0 ]
|
|
EOF
|
|
|
|
# 메일 프로그램 자동 설정(13단계 — frontend/nginx.conf 의 셋째 server)을 실제 요청으로 본다 — 위 단계의 컨테이너(MAIL_DOMAIN=ci.test · MAIL_HOSTNAME=mx1.ci.test).
|
|
# 고정 응답이어야 한다 — 질의 · POST 본문에 꾸민 주소를 넣어도 응답에 되돌아오지 않고, 백엔드로 넘기지 않는다(/api 도 404)
|
|
- name: 메일 프로그램 자동 설정 시험
|
|
run: |
|
|
name="ci-nginx-paths-${{ github.run_id }}"
|
|
docker exec -i "$name" sh -s <<'EOF'
|
|
fails=0
|
|
# host method path [본문] → 응답 전체(머리글 + 본문, CR 없이). 본문이 있으면 XML 로 보낸다
|
|
raw() {
|
|
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf 'Content-Type: text/xml\r\nContent-Length: %s\r\n' "${#4}"; printf 'Connection: close\r\n\r\n'; [ -n "$4" ] && printf '%s' "$4"; sleep 1; } \
|
|
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r'
|
|
}
|
|
ok() { echo "ok $1"; }
|
|
bad() { echo "FAIL $1"; fails=$((fails + 1)); }
|
|
has() { if printf '%s\n' "$2" | grep -F -- "$3" >/dev/null; then ok "$1 — '$3' 있음"; else bad "$1 — '$3' 없음"; fi; }
|
|
hasnt() { if printf '%s\n' "$2" | grep -F -i -- "$3" >/dev/null; then bad "$1 — '$3' 가 되돌아왔다"; else ok "$1 — '$3' 없음"; fi; }
|
|
INJ='%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3Ealert(1)%3C%2Fx%3Ascript%3E%40ci.test'
|
|
r=$(raw autoconfig.ci.test GET "/mail/config-v1.1.xml?emailaddress=$INJ")
|
|
has "Thunderbird" "$r" "HTTP/1.1 200"; has "Thunderbird" "$r" "Content-Type: text/xml; charset=utf-8"
|
|
has "Thunderbird" "$r" "<hostname>mx1.ci.test</hostname>"; has "Thunderbird" "$r" "<port>993</port>"; has "Thunderbird" "$r" "<port>465</port>"
|
|
has "Thunderbird" "$r" "<username>%EMAILADDRESS%</username>"; hasnt "Thunderbird" "$r" "script"; hasnt "Thunderbird" "$r" "pop3"
|
|
r=$(raw autoconfig.ci.test GET /.well-known/autoconfig/mail/config-v1.1.xml)
|
|
has ".well-known" "$r" "<hostname>mx1.ci.test</hostname>"
|
|
BODY='<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress><script>evil@ci.test</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>'
|
|
for p in /autodiscover/autodiscover.xml /AutoDiscover/AutoDiscover.xml /Autodiscover/Autodiscover.xml; do
|
|
r=$(raw autodiscover.ci.test POST "$p" "$BODY")
|
|
has "Outlook $p" "$r" "HTTP/1.1 200"; has "Outlook $p" "$r" "<Server>mx1.ci.test</Server><Port>993</Port>"; has "Outlook $p" "$r" "<Type>SMTP</Type>"
|
|
hasnt "Outlook $p" "$r" "evil"; hasnt "Outlook $p" "$r" "script"
|
|
done
|
|
for p in / /api/auth/me /index.html /s/x /autodiscover/autodiscover.json; do
|
|
r=$(raw autoconfig.ci.test GET "$p"); has "그 밖 $p" "$r" "HTTP/1.1 404"
|
|
done
|
|
# 포털 · 공개 호스트는 이 경로를 자동 설정으로 답하지 않는다
|
|
r=$(raw portal.ci.test GET /mail/config-v1.1.xml); hasnt "포털" "$r" "<clientConfig"
|
|
r=$(raw files.ci.test GET /mail/config-v1.1.xml); has "공개 호스트" "$r" "HTTP/1.1 404"
|
|
echo "fails=$fails"
|
|
[ "$fails" -eq 0 ]
|
|
EOF
|
|
|
|
# 운영 서버의 백업 · 점검 스크립트(13단계 — ops/) — shellcheck(판 · digest 고정). 설정 파일 예시에 비밀이 없는지도 본다
|
|
- name: 운영 스크립트 검사
|
|
run: |
|
|
# 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다). 이 이미지에는 셸이 없다(scratch) — 그대로 shellcheck 를 부른다
|
|
tag="ci-shellcheck:${{ github.run_id }}"
|
|
printf 'FROM koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d\nCOPY . /mnt/ops\nWORKDIR /mnt/ops\n' | docker build -q -t "$tag" -f- ops >/dev/null
|
|
docker run --rm "$tag" -x -P SCRIPTDIR -S style backup/arasystem-backup.sh backup/arasystem-restore.sh monitor/arasystem-check.sh lib/common.sh
|
|
for key in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
|
grep -Eq "^${key}=change-me$" ops/ops.env.example || { echo "::error::ops/ops.env.example 의 $key 는 change-me 여야 합니다"; exit 1; }
|
|
done
|
|
if grep -Eq '^HC_(BACKUP|CHECK)_URL=.+' ops/ops.env.example; then
|
|
echo "::error::ops/ops.env.example 에 Healthchecks 주소가 들어 있습니다"; exit 1
|
|
fi
|
|
|
|
# 대용량 첨부의 악성 검사기(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
|
|
docker image rm -f "ci-shellcheck:${{ 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
|