From 570e8cbe05ea837e8a56664bd74fe653795d7a38 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:33:20 +0900 Subject: [PATCH 1/8] =?UTF-8?q?ci:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=A4=EB=94=A9=C2=B7=EC=9E=A1=20=EB=B3=91=EB=A0=AC=ED=99=94?= =?UTF-8?q?=C2=B7=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20CI?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check 한 잡이 설치→정적 검사→인프라 spec→전체 jest→빌드를 차례로 돌고, coverage-report가 jest를 한 번 더 돌림. coverage-report의 기준 비교는 base에서 npm install이 실패해 head 결과를 기준으로 읽어 차이가 늘 0이었음(PR마다 약 70초 낭비). - pr-check.yml을 static·scripts·build·test(2샤드, fail-fast 끔)·coverage-report·check로 분리 - test: --coverage 유지(LanguageService 모드 선택), 샤드별 임계는 '--coverageThreshold={}'로 끔, --json 결과를 아티팩트(1일)로 - coverage-report: 샤드 결과를 scripts/merge-coverage.ts로 병합 → 테스트 실패·샤드 누락·임계 미달이면 실패(report.json은 먼저 씀), 임계는 jest.config.js - Codecov는 합친 lcov 1회(backend-lcov 그대로) - push면 report.json을 기준 아티팩트(90일, continue-on-error)로, PR이면 이 레포 base 브랜치의 성공한 push 실행에서만 기준을 받음(base 커밋 우선 → 브랜치 최근 → 없으면 head 복사) - 액션은 coverage-file·base-coverage-file로 테스트 재실행 없이 댓글만, 기준 경로 고정(댓글 중복 방지) - check: if: always() + needs 결과가 전부 success인지 jq로 직접 검사(skipped·cancelled도 실패) - actions: read는 coverage-report에만 - node_modules 캐시(yarn.lock·package.json·OS·arch·node 버전 키), 적중이면 설치 대신 prisma generate - image·pr-title·concurrency·워크플로 이름 CI는 그대로 - istanbul-lib-coverage·-report·istanbul-reports(+@types)를 lockfile의 기존 버전 그대로 devDependencies에 명시 - README CI 절·가이드 §9·jest.config.js 주석을 새 구조로 테스트 - merge-coverage.spec(신규): 두 조각 병합 = 단일 실행 수치, 반증으로 조각 하나·샤드 누락·테스트 실패(report.json은 남음)·coverageMap 없음·지원 안 하는 임계 형식, CLI 종료 코드 0/1/2 - deploy-workflow.spec: check 집계 스크립트를 실제 bash로 돌려 success/failure/skipped/cancelled/빈 needs 전수, coverage-report needs·if, 샤드 수 일치, 캐시 키·적중 경로, actions 권한 범위, 기준 받기 스크립트를 gh 대역으로 돌려 순서·중복 제거·head 폴백 - 반증: 샤드 수 검사·테스트 실패 검사 제거, pct 비교 방향, jq success 조건, 중복 제거, coverage-report if, head 폴백을 각각 지우면 실패 확인 - 실제 coverage-final.json(500파일)을 파일 단위 두 조각으로 나눠 병합 → 10050/10267·3773/4087·2019/2075·9143/9283으로 원본과 일치, 한 조각만이면 임계 미달 exit 1 --- .github/workflows/pr-check.yml | 330 +++++++++++++++++++++---- README.md | 30 ++- docs/guide/architecture-conventions.md | 4 +- jest.config.js | 2 +- package.json | 7 + scripts/deploy-workflow.spec.ts | 223 ++++++++++++++++- scripts/merge-coverage.spec.ts | 243 ++++++++++++++++++ scripts/merge-coverage.ts | 179 ++++++++++++++ yarn.lock | 18 +- 9 files changed, 969 insertions(+), 67 deletions(-) create mode 100644 scripts/merge-coverage.spec.ts create mode 100644 scripts/merge-coverage.ts diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7c2f0be1..72e7af98 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -16,7 +16,8 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - check: + # check(필수 체크)는 아래 잡들을 모으는 집계 잡이다. 잡을 나눠 나란히 돌리고, 테스트는 샤드로 쪼갠다. + static: runs-on: ubuntu-latest timeout-minutes: 10 @@ -25,16 +26,35 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js (24.x) & Yarn cache + id: node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "24.x" cache: "yarn" + # node_modules를 통째로 캐시한다 — 공개 레포 PR은 yarn hardened 모드라 설치가 약 49초(push 28초)다. 적중하면 설치를 건너뛴다 + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' run: | corepack enable yarn install --immutable + # 캐시 적중이면 postinstall(prisma generate)이 돌지 않는다 — 생성 클라이언트(src/generated)는 캐시 밖이다 + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + - name: GraphQL codegen run: yarn graphql:codegen @@ -52,17 +72,213 @@ jobs: - name: SDL description coverage check run: yarn docs:check - - name: Script unit tests - run: yarn test:scripts - # 아키텍처 의존 규칙 게이트(순환·Prisma-ban·레이어). 로컬 pre-push(validate:push)뿐 아니라 # 보호된 check status에서도 강제해야 --no-verify/Husky 미설치 환경의 우회를 막는다. - name: Architecture check (dependency-cruiser) run: yarn arch:check - # 전체 회귀·커버리지 임계는 CI(check·coverage-report)가 맡는다 — 로컬 pre-push는 변경에 닿는 spec만 돌린다. - - name: Test with coverage - run: yarn test:cov + scripts: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Script unit tests + run: yarn test:scripts + + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Build (연속 2회 — 증분 캐시 오염 회귀 검출) + run: | + yarn graphql:docs + yarn build + # 왜 2회인가: deleteOutDir이 dist를 지우는데 tsbuildinfo가 dist 밖에 있으면 + # 캐시만 살아남아 tsc가 "전부 최신"으로 오판, emit을 건너뛰고 dist가 빈 채로 + # 남는다. clean 체크아웃 1회 빌드로는 이 상태가 재현되지 않아 PR #259 회귀가 + # 8일간 CI를 그대로 통과했다. 2회차가 그 상태를 만들어 준다. + yarn build + test -f dist/main.js + + # 전체 회귀를 샤드로 나눠 돌린다. 임계는 샤드마다 끄고('--coverageThreshold={}') coverage-report가 합친 맵으로 검사한다 — + # 샤드 하나의 커버리지는 다른 샤드가 덮은 파일을 0으로 세서 늘 미달이다. + # --coverage는 남긴다: jest.config.js가 이 플래그로 LanguageService 모드를 고른다(빼면 branches 수가 달라진다). + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + SHARD: ${{ matrix.shard }} + SHARD_TOTAL: 2 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Test shard (coverage) + run: | + mkdir -p coverage + yarn jest --ci --coverage --shard="$SHARD/$SHARD_TOTAL" --json --outputFile=coverage/report.json --testLocationInResults '--coverageThreshold={}' --coverageReporters=text-summary + + # 테스트가 실패해도 올린다 — coverage-report가 실패 내역을 PR 댓글에 싣는다 + - name: Upload shard result + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-shard-${{ matrix.shard }} + path: coverage/report.json + retention-days: 1 + overwrite: true # 잡 재실행 때 같은 이름 충돌 방지 + + # 샤드 결과를 합쳐 임계를 검사하고(테스트 실패·샤드 누락·임계 미달이면 실패) Codecov에 올린다. 테스트가 실패해도 돌아 + # PR 댓글에 실패를 보인다. push 실행은 합친 report.json을 기준 아티팩트로 남기고, PR 실행은 base 브랜치의 그 아티팩트와 비교한다. + coverage-report: + needs: test + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read # base 브랜치 push 실행의 기준 아티팩트 조회 + pull-requests: write + checks: write + env: + SHARD_TOTAL: 2 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Download shard results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-shard-* + path: coverage/shards + + - name: Merge coverage (임계는 jest.config.js) + run: yarn coverage:merge coverage/shards coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 @@ -76,16 +292,66 @@ jobs: fail_ci_if_error: true verbose: true - - name: Build (연속 2회 — 증분 캐시 오염 회귀 검출) + # PR 비교의 기준. 업로드가 실패해도 배포(main CI 성공)를 막지 않는다 — 다음 push가 다시 올린다 + - name: Upload base coverage (push) + if: github.event_name == 'push' + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report-json + path: coverage/report.json + retention-days: 90 + overwrite: true + + # 기준은 이 레포 base 브랜치의 성공한 push 실행에서만 받는다 — PR 실행의 아티팩트는 PR 코드가 만든 것이라 믿지 않는다. + # base 커밋의 실행이 먼저, 없으면 그 브랜치의 최근 실행. 그래도 없으면 head를 기준 자리에 둔다(차이 0으로 표시). + # 기준 경로는 늘 같아야 한다 — 액션은 입력 경로로 댓글을 식별해 경로가 바뀌면 댓글을 새로 단다. + - name: Fetch base coverage + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/report.json') != '' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.base_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - yarn graphql:docs - yarn build - # 왜 2회인가: deleteOutDir이 dist를 지우는데 tsbuildinfo가 dist 밖에 있으면 - # 캐시만 살아남아 tsc가 "전부 최신"으로 오판, emit을 건너뛰고 dist가 빈 채로 - # 남는다. clean 체크아웃 1회 빌드로는 이 상태가 재현되지 않아 PR #259 회귀가 - # 8일간 CI를 그대로 통과했다. 2회차가 그 상태를 만들어 준다. - yarn build - test -f dist/main.js + runs() { + gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?event=push&status=success&branch=$BASE_REF$1" \ + --jq '.workflow_runs[] | select(.head_repository.full_name == env.REPO) | .id' || true + } + mkdir -p coverage/base + for run in $( { runs "&head_sha=$BASE_SHA&per_page=1"; runs "&per_page=5"; } | awk '!seen[$0]++'); do + if gh run download "$run" -R "$REPO" -n coverage-report-json -D coverage/base; then + echo "기준: $BASE_REF push 실행 $run" + exit 0 + fi + done + echo "::notice::$BASE_REF의 기준 커버리지 아티팩트가 없어 head를 기준으로 둔다(차이 0)" + cp coverage/report.json coverage/base/report.json + + - name: Coverage Report (jest-coverage-report) + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/report.json') != '' }} + uses: ArtiomTr/jest-coverage-report-action@7f750dd50f5585533321eb7ebc482b936b49a5d4 # v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + coverage-file: coverage/report.json + base-coverage-file: coverage/base/report.json + annotations: failed-tests + + # 필수 체크 check. 건너뛴 잡은 GitHub에서 성공으로 보고되고(선행 잡이 실패하면 needs만 건 잡은 skipped), 그 성공이 필수 체크를 + # 통과시킨다 — 그래서 if: always()로 늘 돌고 선행 결과가 전부 success인지 직접 본다. 건너뜀·취소도 실패다. + check: + if: always() + needs: [static, scripts, build, test, coverage-report] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Require every needed job to succeed + env: + NEEDS: ${{ toJSON(needs) }} + run: | + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + echo "$NEEDS" | jq -e 'length > 0 and all(.[]; .result == "success")' > /dev/null # arm64 앱 이미지(홈서버 맥미니). check와 나란히 돌려 배포까지 걸리는 시간을 줄인다 — Deploy는 이 워크플로(CI) 전체가 # 성공해야 시작하므로, check가 실패한 커밋은 이미지가 올라가도 배포되지 않는다. @@ -133,38 +399,6 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - coverage-report: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pull-requests: write - checks: write - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Node.js (24.x) & Yarn cache - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24.x" - cache: "yarn" - - - name: Install dependencies - run: | - corepack enable - yarn install --immutable - - - name: Coverage Report (jest-coverage-report) - uses: ArtiomTr/jest-coverage-report-action@7f750dd50f5585533321eb7ebc482b936b49a5d4 # v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - test-script: yarn jest --coverage --ci - skip-step: install - annotations: failed-tests - pr-title: if: github.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/README.md b/README.md index 19b9839d..6c07a2cb 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,7 @@ yarn test:scripts # 인프라 spec 실행 yarn test:push --dry-run # pre-push가 돌릴 jest 범위 확인 ``` -pre-push는 정적 검사를 모두 돌리고, jest는 변경에 닿는 spec만 실행합니다. SDL·prisma·테스트 인프라·의존성·전역 배선처럼 import 그래프로 추적할 수 없는 변경이면 전체를 돌립니다. 전체 회귀와 커버리지 임계는 CI `check`(필수 체크)가 맡습니다. 개발 머신이 운영 맥미니를 겸하고 테스트 컨테이너가 운영과 같은 VM을 나눠 쓰므로, 전체 테스트(`yarn validate`·`yarn test:cov`)는 한 번에 하나만 돌립니다. 근거는 [아키텍처 컨벤션 §9](./docs/guide/architecture-conventions.md#9-테스트-규약)에 있습니다. +pre-push는 정적 검사를 모두 돌리고, jest는 변경에 닿는 spec만 실행합니다. SDL·prisma·테스트 인프라·의존성·전역 배선처럼 import 그래프로 추적할 수 없는 변경이면 전체를 돌립니다. 전체 회귀는 CI `test` 샤드가, 커버리지 임계는 샤드 결과를 합치는 `coverage-report`가 맡고, 필수 체크 `check`가 둘을 모읍니다. 개발 머신이 운영 맥미니를 겸하고 테스트 컨테이너가 운영과 같은 VM을 나눠 쓰므로, 전체 테스트(`yarn validate`·`yarn test:cov`)는 한 번에 하나만 돌립니다. 근거는 [아키텍처 컨벤션 §9](./docs/guide/architecture-conventions.md#9-테스트-규약)에 있습니다. 검사기와 게이트를 만들 때는 "막아야 할 것을 실제로 막는지"를 확인하는 **반증 케이스**를 테스트의 본체로 둡니다. CI도 같은 testcontainers 구성으로 실행하므로 로컬과 환경 차이가 거의 없습니다. @@ -405,14 +405,26 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ ### 워크플로우 -| Workflow | Trigger | 역할 | -| -------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch 게이트, dto:check(경고만 — 하드 게이트는 pre-push), 인프라 spec, 전체 테스트, 커버리지, 빌드 2회(캐시 회귀 검출)를 실행합니다 | -| `codeql.yml` | PR · push · 주간 | CodeQL 정적 보안 분석을 실행합니다 | -| `knip.yml` · `nestjs-doctor.yml` | PR | 사용하지 않는 코드와 NestJS 점검 결과를 코멘트로 남깁니다(advisory) | -| `pr-check.yml` `image` job | PR(빌드만) · main push(GHCR 푸시) | 테스트와 나란히 arm64 이미지를 빌드해 `ghcr.io/caquick/caquick-be:`로 푸시합니다(가변 태그는 두지 않습니다) | -| `deploy.yml` | main **CI 전체 성공** · 수동(롤백 sha, CI 성공분만) | 셀프호스트 러너(맥미니)가 secrets로 `.env`·`app.env`(600)를 만들고 pull → migrate → worker → api → ready 대기 → 관측 순서로 배포한 뒤 Discord에 알립니다 | -| `discord-notify.yml` | PR · push · issue | Discord에 알림을 보냅니다 | +| Workflow | Trigger | 역할 | +| -------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pr-check.yml` | PR · push (main/develop) | 정적 검사, 인프라 spec, 빌드, 테스트 샤드, 커버리지 병합을 잡으로 나눠 나란히 실행하고 `check`가 결과를 모읍니다(아래 잡 구성) | +| `codeql.yml` | PR · push · 주간 | CodeQL 정적 보안 분석을 실행합니다 | +| `knip.yml` · `nestjs-doctor.yml` | PR | 사용하지 않는 코드와 NestJS 점검 결과를 코멘트로 남깁니다(advisory) | +| `pr-check.yml` `image` job | PR(빌드만) · main push(GHCR 푸시) | 테스트와 나란히 arm64 이미지를 빌드해 `ghcr.io/caquick/caquick-be:`로 푸시합니다(가변 태그는 두지 않습니다) | +| `deploy.yml` | main **CI 전체 성공** · 수동(롤백 sha, CI 성공분만) | 셀프호스트 러너(맥미니)가 secrets로 `.env`·`app.env`(600)를 만들고 pull → migrate → worker → api → ready 대기 → 관측 순서로 배포한 뒤 Discord에 알립니다 | +| `discord-notify.yml` | PR · push · issue | Discord에 알림을 보냅니다 | + +### CI 잡 구성 (`pr-check.yml`) + +- 잡을 나눠 나란히 돌리고, 필수 체크 `check`가 결과를 모읍니다. + - `static`: codegen, tsc, lint, dto:check(경고만 — 하드 게이트는 pre-push), docs:check, arch:check + - `scripts`: 인프라 spec(`test:scripts`) + - `build`: 빌드 2회(증분 캐시 회귀 검출) + - `test`: jest 전체를 2개 샤드로 나눠 실행(샤드별 임계는 끔) + - `coverage-report`: 샤드 커버리지를 합쳐 `jest.config.js`의 임계로 검사하고(`scripts/merge-coverage.ts`) Codecov에 올림. PR이면 base 브랜치 기준과 비교한 댓글을 남김 +- `check`는 건너뛴 잡도 실패로 봅니다. GitHub는 건너뛴 잡을 성공으로 보고해 필수 체크를 통과시키기 때문입니다. +- 커버리지 비교 기준은 develop·main push 실행이 올린 아티팩트입니다. 이 레포 base 브랜치의 성공한 push 실행에서만 받고, 없으면 차이 없이 표시합니다. +- `node_modules`는 `yarn.lock`·`package.json`·OS·node 버전을 키로 캐시합니다. 적중하면 설치 대신 `prisma generate`만 실행합니다. ### 흐름 diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 8518dc1e..288f8103 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -130,12 +130,12 @@ **반증이 본체.** 검사기·게이트·가드는 "막아야 할 것을 실제로 막는지"가 테스트다 — 현재 코드에서 통과하는 것은 오탐이 없다는 뜻일 뿐이다. 입력 공간이 열거 가능하면(SDL 자리, 상태 전이, 에러 종류) `it.each` 전수 표로 고정한다. 일회성 검증 스크립트도 "0건"을 믿기 전에 대상 수를 찍고 일부러 걸리는 항목을 넣어 본다. 로그·API 응답은 필터 전 원본을 먼저 본다. -**정합성 도구.** `yarn validate:push`(Husky pre-push) = lint → tsc → `dto:check`(SDL input ↔ DTO 필드 일치. DTO 없는 input은 기본 lenient 모드에서 정보만, `--strict`에서 오류) → `docs:check`(SDL description 커버리지 — 자명한 필드 `id`·`createdAt`류·`*Id`/`*Ids`는 제외, 임계는 현재 달성치로 고정해 회귀만 차단) → `arch:check`(순환·Prisma-ban·레이어) → `test:scripts:push` → `test:push`. 정적 검사는 전부 돌고, `test:scripts`(scripts/*.spec)는 그 입력(`scripts/**`·`infra/**`·`.github/**`·`.husky/**`·도커 파일·jest 설정·의존성·`tsconfig*.json`)이 바뀌었을 때만 돈다(CI `check`는 항상). jest 범위만 `scripts/pre-push-test-plan.ts`가 기준 커밋(`origin/develop` merge-base, 없으면 `origin/main`, `PRE_PUSH_BASE`로 지정) 대비 작업 트리 변경으로 고른다(`yarn test:push --dry-run`으로 미리 본다). +**정합성 도구.** `yarn validate:push`(Husky pre-push) = lint → tsc → `dto:check`(SDL input ↔ DTO 필드 일치. DTO 없는 input은 기본 lenient 모드에서 정보만, `--strict`에서 오류) → `docs:check`(SDL description 커버리지 — 자명한 필드 `id`·`createdAt`류·`*Id`/`*Ids`는 제외, 임계는 현재 달성치로 고정해 회귀만 차단) → `arch:check`(순환·Prisma-ban·레이어) → `test:scripts:push` → `test:push`. 정적 검사는 전부 돌고, `test:scripts`(scripts/*.spec)는 그 입력(`scripts/**`·`infra/**`·`.github/**`·`.husky/**`·도커 파일·jest 설정·의존성·`tsconfig*.json`)이 바뀌었을 때만 돈다(CI `scripts` 잡은 항상). jest 범위만 `scripts/pre-push-test-plan.ts`가 기준 커밋(`origin/develop` merge-base, 없으면 `origin/main`, `PRE_PUSH_BASE`로 지정) 대비 작업 트리 변경으로 고른다(`yarn test:push --dry-run`으로 미리 본다). - **related**: `src/**/*.ts`만 바뀌면 `jest --findRelatedTests`로 그 파일을 import 그래프로 끌어오는 spec만 돈다. 소스를 파일로 읽어 검사하는 게이트 spec(`src/test/*.spec`, `fs`를 import하는 spec — 모델 소유권·경계 read·잠금 순서 등)은 그래프로 이어지지 않으므로 항상 붙인다. - **full**: 그래프가 못 보는 변경은 전체로 되돌린다. SDL(`*.graphql`, 스키마로 로드), `prisma/**`·`prisma.config.ts`, 테스트 인프라(`src/test/**`·`test/**`), `jest.config.js`, `package.json`(의존성·스크립트), 의존성(`yarn.lock`·`.yarnrc.yml`·`.yarn/**`), `tsconfig*.json`, 전역 배선(`src/config`·`src/global`·`app.module`·`main` — 모듈 배선 spec 전반에 닿는다), src `.ts` 삭제·이름 변경(없어진 모듈의 사용처는 역추적할 수 없다), src 변경 40개 초과, 분류 목록에 없는 파일(`.gitignore`·`nest-cli.json`은 `build-config.spec`이 읽는다), 기준 커밋 해석 실패. - **none**: 문서·`.github`·`infra`·`terraform`·도커·정적 검사 설정(`.dependency-cruiser.cjs`는 `arch:check`가 이미 돈다)·`scripts/**`(`test:scripts`가 전부 돌리고, src는 scripts를 import하지 않는다). -- 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 전체 회귀와 임계(statements 96 / branches 86 / functions 92 / lines 96)는 CI `check`(필수 체크)의 `test:cov`가 맡고, `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. +- 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 같은 이유로 CI도 jest를 샤드로 나눠 돌리되 샤드별 임계는 끄고(`'--coverageThreshold={}'`), `coverage-report` 잡이 샤드 결과를 합쳐(`scripts/merge-coverage.ts`) `jest.config.js`의 임계(statements 96 / branches 86 / functions 92 / lines 96)로 검사한다. 테스트 실패·샤드 누락·임계 미달이면 실패하고, 필수 체크 `check`가 이 결과를 모은다. `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. - ts-jest 변환 모드는 커버리지 여부로 갈린다(`jest.config.js`). `--coverage` 실행만 LanguageService 모드(`isolatedModules:false`)다 — transpile 모드의 데코레이터 메타데이터 가드식이 분기로 잡혀 branches 임계가 깨지기 때문이다. 나머지는 transpile 모드로, 파일별 타입 검사와 전이 의존 mtime 캐시 키가 없어 콜드 실행이 3배 이상 빠르다. 타입 오류는 jest가 아니라 `tsc --noEmit`(validate:push·CI)이 잡는다. CI `check` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). diff --git a/jest.config.js b/jest.config.js index b2c8ce05..c9ca6654 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ // 앱(src) jest 설정. scripts/ spec은 jest.scripts.config.js가 따로 돈다. // // ts-jest 변환 모드를 커버리지 여부로 가른다. -// - 커버리지 실행(CI test:cov·coverage-report): LanguageService 모드(isolatedModules:false). transpile 모드의 데코레이터 +// - 커버리지 실행(CI test 샤드·test:cov): LanguageService 모드(isolatedModules:false). transpile 모드의 데코레이터 // 메타데이터 가드식(`typeof X !== "undefined" && X`)이 분기로 잡혀 branches 임계가 깨진다(a93efac). // - 나머지(pre-push·경로 지정 실행): transpile 모드. LanguageService 모드는 파일마다 타입 검사를 하고 캐시 키에 전이 의존 // 파일의 mtime을 넣어 콜드 실행이 3배 이상 느리다. 타입 검사는 validate:push·CI의 tsc --noEmit이 한다. diff --git a/package.json b/package.json index ccb6c0e8..4b329171 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:scripts": "jest --config jest.scripts.config.js", "test:push": "ts-node scripts/pre-push-test-plan.ts", "test:scripts:push": "ts-node scripts/pre-push-test-plan.ts --scripts", + "coverage:merge": "ts-node scripts/merge-coverage.ts", "arch:check": "depcruise --config .dependency-cruiser.cjs src", "knip": "knip --no-config-hints", "validate": "yarn lint && npx tsc --noEmit && yarn dto:check && yarn docs:check && yarn arch:check && yarn test:scripts && yarn test:cov", @@ -93,6 +94,9 @@ "@types/amqplib": "^0.10", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", + "@types/istanbul-lib-coverage": "2.0.6", + "@types/istanbul-lib-report": "3.0.3", + "@types/istanbul-reports": "3.0.4", "@types/jest": "^30", "@types/node": "^25.8.0", "@types/passport": "^1", @@ -108,6 +112,9 @@ "eslint-plugin-prettier": "^5.5.6", "globals": "^17.11.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "3.2.2", + "istanbul-lib-report": "3.0.1", + "istanbul-reports": "3.2.0", "jest": "^30", "knip": "^6.32.2", "lint-staged": "^17.3.0", diff --git a/scripts/deploy-workflow.spec.ts b/scripts/deploy-workflow.spec.ts index 9d82e625..e34447b3 100644 --- a/scripts/deploy-workflow.spec.ts +++ b/scripts/deploy-workflow.spec.ts @@ -1,4 +1,13 @@ -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'yaml'; @@ -20,6 +29,10 @@ interface Job { 'runs-on': string | string[]; environment?: string; if?: string; + needs?: string | string[]; + strategy?: { 'fail-fast'?: boolean; matrix: Record }; + env?: Record; + permissions?: Record; steps: Step[]; } interface Triggers { @@ -30,6 +43,7 @@ interface Triggers { } interface Workflow { on: Triggers; + permissions?: Record; concurrency?: Record; jobs: Record; } @@ -235,6 +249,213 @@ describe('pr-check.yml run 블록', () => { }); }); +/** GitHub 호스트 러너의 기본 셸(bash -eo pipefail)로 run 블록을 실제로 돌린다. */ +function runStep(run: string, env: Record, cwd?: string) { + return spawnSync( + 'bash', + ['--noprofile', '--norc', '-eo', 'pipefail', '-c', run], + { cwd, env: { ...process.env, ...env }, encoding: 'utf8' }, + ); +} + +describe('pr-check.yml 잡 구성', () => { + const wf = workflow('.github/workflows/pr-check.yml'); + const { check, test, 'coverage-report': coverage } = wf.jobs; + const list = (needs?: string | string[]) => [needs ?? []].flat(); + const step = (job: Job, name: string) => { + const found = job.steps.find((s) => s.name === name); + if (!found) throw new Error(`step 없음: ${name}`); + return found; + }; + + it('필수 체크 check는 if: always()로 늘 돌고, push·PR 모두에서 도는 잡(image·pr-title 제외)을 전부 기다린다', () => { + expect(check.if).toBe('always()'); + const others = Object.keys(wf.jobs).filter( + (name) => !['check', 'image', 'pr-title'].includes(name), + ); + expect(list(check.needs).sort()).toEqual(others.sort()); + }); + + // 건너뛴 잡은 GitHub에서 성공으로 보고된다 — 선행 잡이 실패해 skipped가 된 잡이 필수 체크를 통과시키지 않게 결과를 직접 본다 + it.each([ + ['전부 success면 통과', 0, ['success', 'success', 'success']], + ['반증: failure가 하나라도 있으면 실패', 1, ['success', 'failure']], + ['반증: skipped도 실패', 1, ['success', 'skipped']], + ['반증: cancelled도 실패', 1, ['cancelled', 'success']], + ['반증: needs가 비면 실패', 1, []], + ])('check 집계: %s(종료 코드 %i)', (_label, status, results) => { + const gate = check.steps.find((s) => s.run)!; + expect(gate.env?.NEEDS).toBe('${{ toJSON(needs) }}'); + const needs = Object.fromEntries( + results.map((result, i) => [`job${i}`, { result, outputs: {} }]), + ); + + expect(runStep(gate.run!, { NEEDS: JSON.stringify(needs) }).status).toBe( + status, + ); + }); + + it('test는 fail-fast 없는 샤드 행렬이고, coverage-report는 테스트가 실패해도(취소만 제외) 같은 샤드 수로 합친다', () => { + expect(list(coverage.needs)).toEqual(['test']); + expect(coverage.if).toBe('${{ !cancelled() }}'); + expect(test.strategy?.['fail-fast']).toBe(false); + const shards = test.strategy?.matrix.shard; + expect(shards).toEqual([1, 2]); + expect(Number(test.env?.SHARD_TOTAL)).toBe(shards?.length); + expect(Number(coverage.env?.SHARD_TOTAL)).toBe(shards?.length); + const run = test.steps.find((s) => s.run?.includes('jest'))?.run; + // --coverage는 LanguageService 모드 선택, 샤드별 임계는 끄고 합친 맵으로 검사, 액션 입력 형식(--json·위치) + expect(run).toContain(' --coverage '); + expect(run).toContain('--shard="$SHARD/$SHARD_TOTAL"'); + expect(run).toContain("'--coverageThreshold={}'"); + expect(run).toContain( + '--json --outputFile=coverage/report.json --testLocationInResults', + ); + expect(step(coverage, 'Merge coverage (임계는 jest.config.js)').run).toBe( + 'yarn coverage:merge coverage/shards coverage', + ); + }); + + it('의존성 캐시 키는 yarn.lock·package.json·OS·아키텍처·node 버전을 담고, 적중하면 설치 대신 prisma generate(postinstall)를 돈다', () => { + const cached = Object.entries(wf.jobs).filter(([, job]) => + job.steps.some((s) => s.uses?.startsWith('actions/cache@')), + ); + expect(cached.map(([name]) => name).sort()).toEqual( + ['build', 'coverage-report', 'scripts', 'static', 'test'].sort(), + ); + for (const [, job] of cached) { + const cache = job.steps.find((s) => s.uses?.startsWith('actions/cache@')); + const key = String(cache?.with?.key); + for (const part of [ + '${{ runner.os }}', + '${{ runner.arch }}', + '${{ steps.node.outputs.node-version }}', + "${{ hashFiles('yarn.lock', 'package.json') }}", + ]) + expect(key).toContain(part); + expect( + job.steps.find((s) => s.uses?.startsWith('actions/setup-node@'))?.id, + ).toBe('node'); + expect(step(job, 'Install dependencies')).toMatchObject({ + if: "steps.modules.outputs.cache-hit != 'true'", + run: expect.stringContaining('yarn install --immutable'), + }); + expect( + step(job, 'Prisma generate (node_modules 캐시 적중)'), + ).toMatchObject({ + if: "steps.modules.outputs.cache-hit == 'true'", + run: expect.stringContaining('yarn prisma:generate'), + }); + } + }); + + it('반증: actions: read는 기준 아티팩트를 받는 coverage-report에만 준다', () => { + expect(wf.permissions).toEqual({ contents: 'read' }); + for (const [name, job] of Object.entries(wf.jobs)) { + expect({ name, actions: job.permissions?.actions }).toEqual({ + name, + actions: name === 'coverage-report' ? 'read' : undefined, + }); + } + }); + + describe('기준 커버리지 받기', () => { + const fetch = step(coverage, 'Fetch base coverage'); + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'base-coverage-')); + mkdirSync(join(dir, 'coverage')); + writeFileSync(join(dir, 'coverage/report.json'), '{"head":true}'); + mkdirSync(join(dir, 'bin')); + // gh 대역: api는 조회 종류별 run id를 내고, run download는 HAS에 든 run만 성공한다 + writeFileSync( + join(dir, 'bin/gh'), + [ + '#!/bin/bash', + 'echo "$*" >> "$GH_LOG"', + '[ "$1" = api ] && { [ -n "$API_FAIL" ] && exit 1; case "$2" in *head_sha=*) printf "%s\\n" $EXACT;; *) printf "%s\\n" $LATEST;; esac; exit 0; }', + 'for id in $HAS; do [ "$3" = "$id" ] && { echo "{\\"base\\":$id}" > "${@: -1}/report.json"; exit 0; }; done', + 'exit 1', + ].join('\n'), + ); + chmodSync(join(dir, 'bin/gh'), 0o755); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('반증: 이 레포 base 브랜치의 성공한 push 실행만 조회한다 — PR 실행의 아티팩트는 PR 코드가 만든 것이다', () => { + expect(fetch.run).toContain( + 'actions/workflows/pr-check.yml/runs?event=push&status=success&branch=$BASE_REF', + ); + expect(fetch.run).toContain( + 'select(.head_repository.full_name == env.REPO)', + ); + expect(fetch.env).toMatchObject({ + REPO: '${{ github.repository }}', + BASE_REF: '${{ github.base_ref }}', + BASE_SHA: '${{ github.event.pull_request.base.sha }}', + }); + // 기준 경로는 고정 — 액션은 입력 경로로 댓글을 식별해 경로가 바뀌면 댓글을 새로 단다 + expect( + coverage.steps.find((s) => s.uses?.startsWith('ArtiomTr/'))?.with, + ).toMatchObject({ + 'coverage-file': 'coverage/report.json', + 'base-coverage-file': 'coverage/base/report.json', + }); + }); + + it.each([ + [ + 'base 커밋의 실행이 먼저', + { EXACT: '11', LATEST: '13 12', HAS: '11 12' }, + '{"base":11}', + ['11'], + ], + [ + '그 실행에 아티팩트가 없으면 브랜치 최근 실행(중복은 한 번만)', + { EXACT: '11', LATEST: '11 12', HAS: '12' }, + '{"base":12}', + ['11', '12'], + ], + [ + '반증: 어디에도 없으면 head를 기준 자리에 둔다', + { EXACT: '', LATEST: '13', HAS: '' }, + '{"head":true}', + ['13'], + ], + [ + '반증: 조회가 실패해도 head로 이어간다', + { API_FAIL: '1', HAS: '' }, + '{"head":true}', + [], + ], + ])('%s', (_label, gh, base, tried) => { + const log = join(dir, 'gh.log'); + const result = runStep( + fetch.run!, + { + ...gh, + GH_LOG: log, + PATH: `${join(dir, 'bin')}:${process.env.PATH}`, + REPO: 'CaQuick/caquick-be', + BASE_REF: 'develop', + BASE_SHA: 'abc', + }, + dir, + ); + + expect(result.status).toBe(0); + expect( + readFileSync(join(dir, 'coverage/base/report.json'), 'utf8').trim(), + ).toBe(base); + const downloads = readFileSync(log, 'utf8') + .split('\n') + .filter((line) => line.startsWith('run download')) + .map((line) => line.split(' ')[2]); + expect(downloads).toEqual(tried); + }); + }); +}); + describe('infra/deploy.sh', () => { const script = read('infra/deploy.sh'); diff --git a/scripts/merge-coverage.spec.ts b/scripts/merge-coverage.spec.ts new file mode 100644 index 00000000..8a7a3630 --- /dev/null +++ b/scripts/merge-coverage.spec.ts @@ -0,0 +1,243 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; + +import { + createCoverageMap, + type CoverageMapData, + type FileCoverageData, +} from 'istanbul-lib-coverage'; + +import { + loadThresholds, + mergeCoverage, + parseThresholds, + type Thresholds, +} from './merge-coverage'; + +// CI coverage-report가 샤드 결과를 합쳐 임계를 거는 유일한 지점이다 — 부분 결과가 통과하지 못하는지가 본체다. +const ROOT = join(__dirname, '..'); +const A = join(ROOT, 'src/a.ts'); +const B = join(ROOT, 'src/b.ts'); + +const loc = (line: number) => ({ + start: { line, column: 0 }, + end: { line, column: 10 }, +}); + +/** 문장 2·함수 1·분기 1(경로 2)인 파일. hits = [문장0, 문장1, 함수0, 분기 경로0, 분기 경로1] */ +function file(path: string, hits: number[]): FileCoverageData { + const [s0, s1, f0, b0, b1] = hits; + return { + path, + statementMap: { 0: loc(1), 1: loc(2) }, + fnMap: { 0: { name: 'f', decl: loc(1), loc: loc(1), line: 1 } }, + branchMap: { + 0: { loc: loc(2), type: 'if', locations: [loc(2), loc(3)], line: 2 }, + }, + s: { 0: s0, 1: s1 }, + f: { 0: f0 }, + b: { 0: [b0, b1] }, + }; +} +const FULL = [1, 1, 1, 1, 1]; +const NONE = [0, 0, 0, 0, 0]; +const PARTIAL = [1, 0, 1, 1, 0]; + +/** jest --json 출력의 모양. jest는 샤드가 읽지 않은 파일도 collectCoverageFrom으로 0을 채워 넣는다 */ +function shard( + files: FileCoverageData[], + { passed, failed = 0 }: { passed: number; failed?: number }, +): Record { + return { + success: failed === 0, + startTime: 1000 + passed, + numTotalTests: passed + failed, + numPassedTests: passed, + numFailedTests: failed, + numRuntimeErrorTestSuites: 0, + snapshot: { failure: false, total: 0 }, + testResults: [ + { + name: join(ROOT, `src/${passed}-${failed}.spec.ts`), + assertionResults: [ + { status: failed ? 'failed' : 'passed', title: '케이스' }, + ], + }, + ], + coverageMap: Object.fromEntries(files.map((f) => [f.path, f])), + }; +} + +const LOOSE: Thresholds = { + statements: 75, + branches: 75, + functions: 100, + lines: 75, +}; + +describe('merge-coverage', () => { + let shardDir: string; + let outDir: string; + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'merge-coverage-')); + shardDir = join(dir, 'shards'); + outDir = join(dir, 'out'); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const put = (n: number, report: Record) => { + mkdirSync(join(shardDir, `coverage-shard-${n}`), { recursive: true }); + writeFileSync( + join(shardDir, `coverage-shard-${n}`, 'report.json'), + JSON.stringify(report), + ); + }; + const report = () => + JSON.parse(readFileSync(join(outDir, 'report.json'), 'utf8')) as Record< + string, + unknown + > & { coverageMap: CoverageMapData; testResults: unknown[] }; + const merge = (shardTotal: number, thresholds: Thresholds) => + mergeCoverage({ shardDir, outDir, shardTotal, thresholds }); + + it('두 샤드를 합치면 한 번에 돈 결과와 수치가 같고, 테스트 수를 더해 report.json·coverage-final.json·lcov.info를 쓴다', () => { + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 2 })); + put(2, shard([file(A, NONE), file(B, PARTIAL)], { passed: 3 })); + const single = createCoverageMap({ + [A]: file(A, FULL), + [B]: file(B, PARTIAL), + }) + .getCoverageSummary() + .toJSON(); + + expect(merge(2, LOOSE)).toEqual({ errors: [], summary: single }); + expect(single.statements).toMatchObject({ covered: 3, total: 4, pct: 75 }); + const merged = report(); + expect(merged).toMatchObject({ + success: true, + startTime: 1002, + numTotalTests: 5, + numPassedTests: 5, + numFailedTests: 0, + snapshot: { failure: false, total: 0 }, + }); + expect(merged.testResults).toHaveLength(2); + expect( + createCoverageMap(merged.coverageMap).getCoverageSummary().toJSON(), + ).toEqual(single); + expect(existsSync(join(outDir, 'coverage-final.json'))).toBe(true); + // Codecov가 받는 경로는 지금처럼 저장소 루트 기준 상대 경로 + expect(readFileSync(join(outDir, 'lcov.info'), 'utf8')).toContain( + `SF:${relative(process.cwd(), B)}\n`, + ); + }); + + it('반증: 샤드 하나만으로는 임계 미달로 실패한다 — 다른 샤드가 덮은 파일이 0으로 잡힌다', () => { + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 2 })); + + expect(merge(1, LOOSE).errors).toEqual([ + 'statements 50% — 임계 75% 미달', + 'branches 50% — 임계 75% 미달', + 'functions 50% — 임계 100% 미달', + 'lines 50% — 임계 75% 미달', + ]); + }); + + it('반증: 샤드 결과가 모자라면 실패하고 report.json의 success도 거짓으로 쓴다', () => { + put(1, shard([file(A, FULL), file(B, FULL)], { passed: 2 })); + + expect(merge(2, {}).errors).toEqual(['샤드 결과 1개 — 2개여야 한다']); + expect(report().success).toBe(false); + }); + + it('반증: 실패한 테스트가 있으면 실패하고, 그때도 report.json을 써서 실패 내역을 남긴다', () => { + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 2 })); + put(2, shard([file(A, NONE), file(B, FULL)], { passed: 2, failed: 1 })); + + expect(merge(2, LOOSE).errors).toEqual([ + '테스트 실패 — 실패 1건, 실행 오류 스위트 0개', + ]); + const merged = report(); + expect(merged).toMatchObject({ + success: false, + numFailedTests: 1, + numTotalTests: 5, + }); + expect(merged.testResults[1]).toMatchObject({ + assertionResults: [{ status: 'failed' }], + }); + }); + + it('반증: coverageMap이 없는 샤드는 실패한다 — 빈 맵은 istanbul이 임계 비교를 통과하는 값으로 센다', () => { + put(1, { ...shard([], { passed: 2 }), coverageMap: undefined }); + + expect(merge(1, { statements: 1 }).errors).toEqual([ + 'coverageMap이 없는 샤드가 있다(--coverage 누락)', + 'statements Unknown% — 임계 1% 미달', + ]); + }); + + describe('CLI(yarn coverage:merge) 종료 코드 — CI 잡의 성패가 이것으로 갈린다', () => { + it.each([ + ['샤드가 다 있고 jest.config.js 임계를 넘으면 0', [1, 2], '2', 0, true], + ['반증: 샤드가 모자라면 1, report.json은 남는다', [1], '2', 1, true], + ['반증: SHARD_TOTAL이 없으면 2', [1, 2], '', 2, false], + ])( + '%s', + (_label, shards, total, status, written) => { + if (shards.includes(1)) + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 1 })); + if (shards.includes(2)) + put(2, shard([file(A, NONE), file(B, FULL)], { passed: 1 })); + + const result = spawnSync('yarn', ['coverage:merge', shardDir, outDir], { + cwd: ROOT, + env: { ...process.env, SHARD_TOTAL: total }, + encoding: 'utf8', + }); + + expect({ status: result.status, stderr: result.stderr }).toMatchObject({ + status, + }); + expect(existsSync(join(outDir, 'report.json'))).toBe(written); + }, + 60_000, + ); + }); +}); + +describe('임계 설정', () => { + it('jest.config.js의 global 임계를 그대로 읽는다', () => { + const config = require('../jest.config.js') as { + coverageThreshold: { global: Thresholds }; + }; + expect(loadThresholds()).toEqual(config.coverageThreshold.global); + }); + + it.each([ + [undefined], + [{}], + [{ global: {} }], + [{ global: { statements: 90 }, './src/a.ts': { lines: 90 } }], + [{ global: { statements: -10 } }], + [{ global: { statement: 90 } }], + [{ global: { lines: '90' } }], + ])( + '반증: %j는 거절한다 — 여기서 검사하지 못하는 형식을 조용히 통과시키지 않는다', + (config) => { + expect(() => parseThresholds(config)).toThrow( + '지원하지 않는 coverageThreshold', + ); + }, + ); +}); diff --git a/scripts/merge-coverage.ts b/scripts/merge-coverage.ts new file mode 100644 index 00000000..562280dd --- /dev/null +++ b/scripts/merge-coverage.ts @@ -0,0 +1,179 @@ +/** + * CI 테스트 샤드의 jest --json 결과를 한 벌로 합친다 — 커버리지 맵 병합, 테스트 결과 합산, 임계 검사. + * + * 왜: 샤드 하나의 커버리지는 다른 샤드가 덮은 파일을 0으로 세서 임계와 비교할 수 없다. 그래서 샤드는 임계를 끄고 + * ('--coverageThreshold={}') 돌리고, 임계는 합친 맵으로 여기서 검사한다. 값은 jest.config.js 한 곳에서 읽는다. + * + * 사용: SHARD_TOTAL=<샤드 수> yarn coverage:merge <샤드 디렉터리> <출력 디렉터리> + * 샤드 디렉터리의 하위 폴더마다 report.json(jest --coverage --json --testLocationInResults 출력)이 있어야 한다. + * 출력은 report.json(jest-coverage-report-action 입력)·coverage-final.json·lcov.info. + * 테스트 실패·샤드 누락·임계 미달이면 exit 1 — 그때도 report.json은 먼저 써서 PR 댓글에 실패가 보이게 한다. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +import { + createCoverageMap, + type CoverageMapData, + type CoverageSummaryData, +} from 'istanbul-lib-coverage'; +import { createContext } from 'istanbul-lib-report'; +import { create } from 'istanbul-reports'; + +const METRICS = ['statements', 'branches', 'functions', 'lines'] as const; +export type Thresholds = Partial>; + +type Report = Record & { coverageMap?: CoverageMapData }; + +export interface MergeResult { + errors: string[]; + summary: CoverageSummaryData; +} + +/** jest coverageThreshold에서 global의 양수 퍼센트만 받는다 — 경로별 그룹·음수(미커버 개수)는 여기서 검사하지 못해 거절한다. */ +export function parseThresholds(coverageThreshold: unknown): Thresholds { + const { global, ...rest } = (coverageThreshold ?? {}) as Record< + string, + Record + >; + const entries = Object.entries(global ?? {}); + const ok = + Object.keys(rest).length === 0 && + entries.length > 0 && + entries.every( + ([key, value]) => + (METRICS as readonly string[]).includes(key) && + typeof value === 'number' && + value > 0, + ); + if (!ok) { + throw new Error( + `지원하지 않는 coverageThreshold: ${JSON.stringify(coverageThreshold)}`, + ); + } + return global; +} + +export function loadThresholds(): Thresholds { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const config = require('../jest.config.js') as { + coverageThreshold?: unknown; + }; + return parseThresholds(config.coverageThreshold); +} + +/** 수는 더하고(startTime은 가장 이른 값), success는 AND, 다른 참/거짓은 OR, 배열은 잇고, 객체(snapshot)는 같은 규칙으로 */ +function combine(a: unknown, b: unknown, key: string): unknown { + if (a === undefined) return b; + if (b === undefined) return a; + if (key === 'startTime') return Math.min(Number(a), Number(b)); + if (key === 'success') return a === true && b === true; + if (typeof a === 'number' && typeof b === 'number') return a + b; + if (typeof a === 'boolean' && typeof b === 'boolean') return a || b; + if (Array.isArray(a) && Array.isArray(b)) + return [...(a as unknown[]), ...(b as unknown[])]; + if (a && b && typeof a === 'object' && typeof b === 'object') { + const x = a as Record; + const y = b as Record; + const keys = new Set([...Object.keys(x), ...Object.keys(y)]); + return Object.fromEntries( + [...keys].map((k) => [k, combine(x[k], y[k], k)]), + ); + } + return a; +} + +function readShards(shardDir: string): Report[] { + if (!existsSync(shardDir)) return []; + return readdirSync(shardDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(shardDir, entry.name, 'report.json')) + .filter((file) => existsSync(file)) + .sort() + .map((file) => JSON.parse(readFileSync(file, 'utf8')) as Report); +} + +export function mergeCoverage(opts: { + shardDir: string; + outDir: string; + shardTotal: number; + thresholds: Thresholds; +}): MergeResult { + const shards = readShards(opts.shardDir); + const errors: string[] = []; + if (shards.length !== opts.shardTotal) { + errors.push(`샤드 결과 ${shards.length}개 — ${opts.shardTotal}개여야 한다`); + } + // 맵 없는 샤드는 그 샤드가 덮은 파일이 빠진 채 합쳐진다 — 커버리지를 안 잰 실행을 통과시키지 않는다 + if (shards.some((shard) => !shard.coverageMap)) { + errors.push('coverageMap이 없는 샤드가 있다(--coverage 누락)'); + } + + const map = createCoverageMap({}); + for (const shard of shards) map.merge(shard.coverageMap ?? {}); + const tests = shards + .map(({ coverageMap: _coverageMap, ...rest }) => rest) + .reduce>( + (acc, shard) => combine(acc, shard, '') as Record, + {}, + ); + const passed = tests.success === true && errors.length === 0; + if (shards.length > 0 && tests.success !== true) { + errors.push( + `테스트 실패 — 실패 ${Number(tests.numFailedTests ?? 0)}건, 실행 오류 스위트 ${Number(tests.numRuntimeErrorTestSuites ?? 0)}개`, + ); + } + + mkdirSync(opts.outDir, { recursive: true }); + writeFileSync( + join(opts.outDir, 'report.json'), + JSON.stringify({ ...tests, success: passed, coverageMap: map.toJSON() }), + ); + const context = createContext({ dir: opts.outDir, coverageMap: map }); + create('json').execute(context); + create('lcovonly').execute(context); + + const summary = map.getCoverageSummary().toJSON(); + for (const [metric, min] of Object.entries(opts.thresholds)) { + const { pct } = summary[metric as keyof CoverageSummaryData]; + // 빈 맵의 pct는 'Unknown'(문자열)이라 pct < min이 거짓이 된다 — 부정형으로 비교해 미달로 센다 + if (!(pct >= min)) errors.push(`${metric} ${pct}% — 임계 ${min}% 미달`); + } + return { errors, summary }; +} + +function main(): void { + const [shardDir, outDir] = process.argv.slice(2); + const shardTotal = Number(process.env.SHARD_TOTAL); + if (!shardDir || !outDir || !Number.isInteger(shardTotal) || shardTotal < 1) { + console.error( + '사용: SHARD_TOTAL=<샤드 수> yarn coverage:merge <샤드 디렉터리> <출력 디렉터리>', + ); + process.exit(2); + } + const thresholds = loadThresholds(); + const { errors, summary } = mergeCoverage({ + shardDir, + outDir, + shardTotal, + thresholds, + }); + for (const metric of METRICS) { + const { covered, total, pct } = summary[metric]; + const min = thresholds[metric]; + console.log( + `${metric.padEnd(10)} ${pct}% (${covered}/${total})${min ? ` 임계 ${min}%` : ''}`, + ); + } + for (const error of errors) console.error(`✖ ${error}`); + process.exit(errors.length > 0 ? 1 : 0); +} + +if (require.main === module) main(); diff --git a/yarn.lock b/yarn.lock index 0254ccc3..39da4d0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5722,14 +5722,14 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:2.0.6, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 languageName: node linkType: hard -"@types/istanbul-lib-report@npm:*": +"@types/istanbul-lib-report@npm:*, @types/istanbul-lib-report@npm:3.0.3": version: 3.0.3 resolution: "@types/istanbul-lib-report@npm:3.0.3" dependencies: @@ -5738,7 +5738,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.4": +"@types/istanbul-reports@npm:3.0.4, @types/istanbul-reports@npm:^3.0.4": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -8060,6 +8060,9 @@ __metadata: "@types/amqplib": "npm:^0.10" "@types/cookie-parser": "npm:^1.4.10" "@types/express": "npm:^5.0.6" + "@types/istanbul-lib-coverage": "npm:2.0.6" + "@types/istanbul-lib-report": "npm:3.0.3" + "@types/istanbul-reports": "npm:3.0.4" "@types/jest": "npm:^30" "@types/node": "npm:^25.8.0" "@types/passport": "npm:^1" @@ -8087,6 +8090,9 @@ __metadata: graphql-ws: "npm:^6.2.1" husky: "npm:^9.1.7" ioredis: "npm:^5.3.2" + istanbul-lib-coverage: "npm:3.2.2" + istanbul-lib-report: "npm:3.0.1" + istanbul-reports: "npm:3.2.0" jest: "npm:^30" knip: "npm:^6.32.2" lint-staged: "npm:^17.3.0" @@ -12395,7 +12401,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": +"istanbul-lib-coverage@npm:3.2.2, istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b @@ -12415,7 +12421,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-report@npm:^3.0.0": +"istanbul-lib-report@npm:3.0.1, istanbul-lib-report@npm:^3.0.0": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" dependencies: @@ -12437,7 +12443,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.3": +"istanbul-reports@npm:3.2.0, istanbul-reports@npm:^3.1.3": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: From 5fbacc05795945c67c51563e8a6e40625d721174 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:42:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?ci:=20Codecov=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20PR=20=EB=8C=93=EA=B8=80=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C,=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20sp?= =?UTF-8?q?ec=EC=9D=98=20=EC=85=B8=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=85=B8=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 실행 로그에서 codecov-action이 CC_TOKEN 등을 GITHUB_ENV로 내보내 뒤 단계(기준 받기 gh 스크립트·서드파티 댓글 액션) env에 남는 것 확인 - 예전엔 Codecov(check)와 댓글 액션(coverage-report)이 다른 잡이라 같은 env에 없었음 → Codecov를 coverage-report 마지막으로 옮겨 그대로 분리 - deploy-workflow.spec의 run 블록 실행 셸을 bash -eo pipefail → bash -e로(shell 미지정 run의 실제 셸, 로그의 "/usr/bin/bash -e {0}") - 집계·기준 받기 스크립트는 pipefail 유무와 무관하게 같은 결과(마지막 명령의 종료 코드로 판정) 테스트 - deploy-workflow.spec: Codecov 단계가 기준 받기·댓글 액션보다 뒤인지 고정 --- .github/workflows/pr-check.yml | 25 +++++++++++++------------ scripts/deploy-workflow.spec.ts | 25 +++++++++++++++++++------ 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 72e7af98..699ecbc8 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -280,18 +280,6 @@ jobs: - name: Merge coverage (임계는 jest.config.js) run: yarn coverage:merge coverage/shards coverage - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info - # disable_search: 지정한 파일만 업로드 (자동 탐색 결과와 섞이지 않도록) - disable_search: true - name: backend-lcov - # 업로드 실패 시 조용히 넘어가지 않고 CI 실패로 가시화 (진단/운영 모두 권장). - fail_ci_if_error: true - verbose: true - # PR 비교의 기준. 업로드가 실패해도 배포(main CI 성공)를 막지 않는다 — 다음 push가 다시 올린다 - name: Upload base coverage (push) if: github.event_name == 'push' @@ -337,6 +325,19 @@ jobs: base-coverage-file: coverage/base/report.json annotations: failed-tests + # 댓글 액션 뒤에 둔다 — codecov-action은 업로드 토큰(CC_TOKEN)을 GITHUB_ENV로 내보내 뒤 단계 env에 남긴다 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + # disable_search: 지정한 파일만 업로드 (자동 탐색 결과와 섞이지 않도록) + disable_search: true + name: backend-lcov + # 업로드 실패 시 조용히 넘어가지 않고 CI 실패로 가시화 (진단/운영 모두 권장). + fail_ci_if_error: true + verbose: true + # 필수 체크 check. 건너뛴 잡은 GitHub에서 성공으로 보고되고(선행 잡이 실패하면 needs만 건 잡은 skipped), 그 성공이 필수 체크를 # 통과시킨다 — 그래서 if: always()로 늘 돌고 선행 결과가 전부 success인지 직접 본다. 건너뜀·취소도 실패다. check: diff --git a/scripts/deploy-workflow.spec.ts b/scripts/deploy-workflow.spec.ts index e34447b3..6c3d6208 100644 --- a/scripts/deploy-workflow.spec.ts +++ b/scripts/deploy-workflow.spec.ts @@ -249,13 +249,13 @@ describe('pr-check.yml run 블록', () => { }); }); -/** GitHub 호스트 러너의 기본 셸(bash -eo pipefail)로 run 블록을 실제로 돌린다. */ +/** shell을 지정하지 않은 run 블록과 같은 셸(bash -e {0}, 실행 로그로 확인)로 실제로 돌린다. */ function runStep(run: string, env: Record, cwd?: string) { - return spawnSync( - 'bash', - ['--noprofile', '--norc', '-eo', 'pipefail', '-c', run], - { cwd, env: { ...process.env, ...env }, encoding: 'utf8' }, - ); + return spawnSync('bash', ['-e', '-c', run], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); } describe('pr-check.yml 잡 구성', () => { @@ -349,6 +349,19 @@ describe('pr-check.yml 잡 구성', () => { } }); + it('반증: Codecov 업로드는 기준 받기·댓글 액션 뒤에 둔다 — codecov-action이 업로드 토큰을 GITHUB_ENV로 뒤 단계에 남긴다', () => { + const at = (prefix: string) => + coverage.steps.findIndex((s) => + (s.uses ?? s.name ?? '').startsWith(prefix), + ); + expect(at('codecov/codecov-action@')).toBeGreaterThan( + at('ArtiomTr/jest-coverage-report-action@'), + ); + expect(at('codecov/codecov-action@')).toBeGreaterThan( + at('Fetch base coverage'), + ); + }); + it('반증: actions: read는 기준 아티팩트를 받는 coverage-report에만 준다', () => { expect(wf.permissions).toEqual({ contents: 'read' }); for (const [name, job] of Object.entries(wf.jobs)) { From d3fd70957bcc9ba3f4f63d825cf875ef1cc49f65 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:43:28 +0900 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20README.en=20CI=20=EC=A0=88=EC=9D=84?= =?UTF-8?q?=20=EC=83=88=20=EC=9E=A1=20=EA=B5=AC=EC=84=B1=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한국어 README와 같게: pr-check.yml 역할 문구, CI 잡 구성 절 추가, 전체 회귀·임계 담당 문장 --- README.en.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/README.en.md b/README.en.md index 3610968e..3db3bb38 100644 --- a/README.en.md +++ b/README.en.md @@ -374,7 +374,7 @@ yarn test:scripts # infrastructure specs yarn test:push --dry-run # the Jest scope pre-push would run ``` -The pre-push hook runs every static check but only the Jest specs the change reaches. Changes the import graph cannot track (SDL, prisma, test infrastructure, dependencies, global wiring) fall back to the full suite. Full regression and the coverage thresholds belong to the required CI `check`. The development machine doubles as the production Mac mini and the test containers share its VM with production, so full test runs (`yarn validate`, `yarn test:cov`) go one at a time. The reasoning is in [architecture conventions §9](./docs/guide/architecture-conventions.md) (Korean). +The pre-push hook runs every static check but only the Jest specs the change reaches. Changes the import graph cannot track (SDL, prisma, test infrastructure, dependencies, global wiring) fall back to the full suite. Full regression runs in the CI `test` shards, the coverage thresholds are checked by `coverage-report` on the merged shard results, and the required `check` collects both. The development machine doubles as the production Mac mini and the test containers share its VM with production, so full test runs (`yarn validate`, `yarn test:cov`) go one at a time. The reasoning is in [architecture conventions §9](./docs/guide/architecture-conventions.md) (Korean). When a checker or gate is added, the **refutation cases** (proving it actually blocks what it should) are the core of its tests. CI uses the same testcontainers setup, so there is little difference between local and CI environments. @@ -405,14 +405,26 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ ### Workflows -| Workflow | Trigger | Role | -| -------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch gates, dto:check (warning only; the hard gate is the pre-push hook), infrastructure specs, integration tests, coverage, and two consecutive builds (cache regression check) | -| `codeql.yml` | PR · push · weekly | CodeQL static security analysis | -| `knip.yml` · `nestjs-doctor.yml` | PR | comments with unused-code and NestJS health reports (advisory) | -| `pr-check.yml` `image` job | PR (build only) · main push (push) | builds an arm64 image alongside the tests and pushes `ghcr.io/caquick/caquick-be:` (no mutable tags) | -| `deploy.yml` | main **CI fully succeeds** · manual (rollback sha, CI-passed only) | the self-hosted runner (Mac mini) writes `.env` and `app.env` (mode 600) from secrets, then pull → migrate → worker → api → readiness wait → observability, and notifies Discord | -| `discord-notify.yml` | PR · push · issue | Discord notifications | +| Workflow | Trigger | Role | +| -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pr-check.yml` | PR · push (main/develop) | runs static checks, infrastructure specs, the build, the test shards and the coverage merge as parallel jobs; `check` collects the results (see job layout below) | +| `codeql.yml` | PR · push · weekly | CodeQL static security analysis | +| `knip.yml` · `nestjs-doctor.yml` | PR | comments with unused-code and NestJS health reports (advisory) | +| `pr-check.yml` `image` job | PR (build only) · main push (push) | builds an arm64 image alongside the tests and pushes `ghcr.io/caquick/caquick-be:` (no mutable tags) | +| `deploy.yml` | main **CI fully succeeds** · manual (rollback sha, CI-passed only) | the self-hosted runner (Mac mini) writes `.env` and `app.env` (mode 600) from secrets, then pull → migrate → worker → api → readiness wait → observability, and notifies Discord | +| `discord-notify.yml` | PR · push · issue | Discord notifications | + +### CI job layout (`pr-check.yml`) + +- Jobs run in parallel and the required `check` collects their results. + - `static`: codegen, tsc, lint, dto:check (warning only; the hard gate is the pre-push hook), docs:check, arch:check + - `scripts`: infrastructure specs (`test:scripts`) + - `build`: two consecutive builds (incremental-cache regression check) + - `test`: the full Jest suite split into 2 shards (per-shard thresholds off) + - `coverage-report`: merges the shard coverage, checks it against the `jest.config.js` thresholds (`scripts/merge-coverage.ts`) and uploads to Codecov. On a PR it also comments a comparison against the base branch +- `check` treats skipped jobs as failures, because GitHub reports a skipped job as successful and that would satisfy a required check. +- The comparison baseline is the artifact uploaded by develop/main push runs. It is only taken from successful push runs of the base branch in this repository; without one the comment shows no delta. +- `node_modules` is cached by `yarn.lock`, `package.json`, OS and Node version. On a hit only `prisma generate` runs instead of the install. ### Flow From ef7ed8822f81c89dc143c882a09c6787741bdcc6 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:43:50 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=9E=84=EA=B3=84=20=EC=83=81=ED=96=A5=20=E2=80=94?= =?UTF-8?q?=20statements=2097=C2=B7branches=2092=C2=B7functions=2097=C2=B7?= =?UTF-8?q?lines=2098?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 96/86/92/96 → 97/92/97/98(실측 정수 내림, 사용자 결정) - 근거: 이 PR 첫 CI(run 37221170062)의 샤드 병합 수치 97.90/92.42/97.45/98.51 - 10063/10278·3797/4108·2026/2079·9154/9292 — 같은 트리를 단일 실행한 #492 리포트와 개수까지 일치 - 임계는 jest.config.js 한 곳(로컬 test:cov는 jest가, CI는 coverage-report의 merge-coverage가 같은 값으로 검사) - README·README.en·가이드 §9·jest.scripts.config.js 주석의 수치 갱신 --- README.en.md | 4 ++-- README.md | 4 ++-- docs/guide/architecture-conventions.md | 2 +- jest.config.js | 2 +- jest.scripts.config.js | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.en.md b/README.en.md index 3db3bb38..754e2dda 100644 --- a/README.en.md +++ b/README.en.md @@ -130,7 +130,7 @@ Customers end up hopping between platforms, combining screenshots, edits, and ex - Static checks: **ESLint** (strict rules plus the `boundaries` plugin enforcing feature boundaries) and **Prettier**, run by **Husky** and **lint-staged** before commit and push. - Structural checks: **dependency-cruiser** gates layer direction and forbids cycles; **knip** reports unused code. - Commit messages: **commitlint** enforces Conventional Commits. -- Coverage, security, dependencies: **Codecov** (patch 80%; global thresholds statements 96%, branches 86%), **CodeQL**, **Dependabot** +- Coverage, security, dependencies: **Codecov** (patch 80%; global thresholds statements 97%, branches 92%), **CodeQL**, **Dependabot** - Repo-specific gates - `dto:check`: SDL inputs ↔ DTO classes stay in sync - `docs:check`: SDL description coverage @@ -369,7 +369,7 @@ extend type Query { ```bash yarn test # everything (Docker required) yarn test src/features/order # a single domain -yarn test:cov # coverage (thresholds: statements 96 / branches 86 / functions 92 / lines 96) +yarn test:cov # coverage (thresholds: statements 97 / branches 92 / functions 97 / lines 98) yarn test:scripts # infrastructure specs yarn test:push --dry-run # the Jest scope pre-push would run ``` diff --git a/README.md b/README.md index 6c07a2cb..f1a09c10 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ - 정적 검사: **ESLint**(strict 규칙 + `boundaries` 플러그인으로 feature 경계 강제) · **Prettier**. **Husky**와 **lint-staged**가 커밋·push 전에 실행합니다. - 구조 검사: **dependency-cruiser**(레이어 방향·순환 의존 금지)가 게이트로 막고, **knip**은 사용하지 않는 코드를 리포트합니다. - 커밋 메시지: **commitlint**가 Conventional Commits 형식을 검사합니다. -- 커버리지·보안·의존성: **Codecov**(patch 80%, 전역 statements 96% · branches 86%) · **CodeQL** · **Dependabot** +- 커버리지·보안·의존성: **Codecov**(patch 80%, 전역 statements 97% · branches 92%) · **CodeQL** · **Dependabot** - 이 레포만의 게이트 - `dto:check`: SDL input ↔ DTO class 동기화 - `docs:check`: SDL description 커버리지 @@ -369,7 +369,7 @@ extend type Query { ```bash yarn test # 전체 실행 (Docker 필요) yarn test src/features/order # 특정 도메인만 실행 -yarn test:cov # 커버리지 측정 (임계값: statements 96 / branches 86 / functions 92 / lines 96) +yarn test:cov # 커버리지 측정 (임계값: statements 97 / branches 92 / functions 97 / lines 98) yarn test:scripts # 인프라 spec 실행 yarn test:push --dry-run # pre-push가 돌릴 jest 범위 확인 ``` diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 288f8103..1c2ca95d 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -135,7 +135,7 @@ - **related**: `src/**/*.ts`만 바뀌면 `jest --findRelatedTests`로 그 파일을 import 그래프로 끌어오는 spec만 돈다. 소스를 파일로 읽어 검사하는 게이트 spec(`src/test/*.spec`, `fs`를 import하는 spec — 모델 소유권·경계 read·잠금 순서 등)은 그래프로 이어지지 않으므로 항상 붙인다. - **full**: 그래프가 못 보는 변경은 전체로 되돌린다. SDL(`*.graphql`, 스키마로 로드), `prisma/**`·`prisma.config.ts`, 테스트 인프라(`src/test/**`·`test/**`), `jest.config.js`, `package.json`(의존성·스크립트), 의존성(`yarn.lock`·`.yarnrc.yml`·`.yarn/**`), `tsconfig*.json`, 전역 배선(`src/config`·`src/global`·`app.module`·`main` — 모듈 배선 spec 전반에 닿는다), src `.ts` 삭제·이름 변경(없어진 모듈의 사용처는 역추적할 수 없다), src 변경 40개 초과, 분류 목록에 없는 파일(`.gitignore`·`nest-cli.json`은 `build-config.spec`이 읽는다), 기준 커밋 해석 실패. - **none**: 문서·`.github`·`infra`·`terraform`·도커·정적 검사 설정(`.dependency-cruiser.cjs`는 `arch:check`가 이미 돈다)·`scripts/**`(`test:scripts`가 전부 돌리고, src는 scripts를 import하지 않는다). -- 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 같은 이유로 CI도 jest를 샤드로 나눠 돌리되 샤드별 임계는 끄고(`'--coverageThreshold={}'`), `coverage-report` 잡이 샤드 결과를 합쳐(`scripts/merge-coverage.ts`) `jest.config.js`의 임계(statements 96 / branches 86 / functions 92 / lines 96)로 검사한다. 테스트 실패·샤드 누락·임계 미달이면 실패하고, 필수 체크 `check`가 이 결과를 모은다. `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. +- 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 같은 이유로 CI도 jest를 샤드로 나눠 돌리되 샤드별 임계는 끄고(`'--coverageThreshold={}'`), `coverage-report` 잡이 샤드 결과를 합쳐(`scripts/merge-coverage.ts`) `jest.config.js`의 임계(statements 97 / branches 92 / functions 97 / lines 98)로 검사한다. 테스트 실패·샤드 누락·임계 미달이면 실패하고, 필수 체크 `check`가 이 결과를 모은다. `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. - ts-jest 변환 모드는 커버리지 여부로 갈린다(`jest.config.js`). `--coverage` 실행만 LanguageService 모드(`isolatedModules:false`)다 — transpile 모드의 데코레이터 메타데이터 가드식이 분기로 잡혀 branches 임계가 깨지기 때문이다. 나머지는 transpile 모드로, 파일별 타입 검사와 전이 의존 mtime 캐시 키가 없어 콜드 실행이 3배 이상 빠르다. 타입 오류는 jest가 아니라 `tsc --noEmit`(validate:push·CI)이 잡는다. CI `check` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). diff --git a/jest.config.js b/jest.config.js index c9ca6654..931bf2eb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -43,7 +43,7 @@ module.exports = { '!test/**', ], coverageThreshold: { - global: { statements: 96, branches: 86, functions: 92, lines: 96 }, + global: { statements: 97, branches: 92, functions: 97, lines: 98 }, }, coverageDirectory: '../coverage', testEnvironment: 'node', diff --git a/jest.scripts.config.js b/jest.scripts.config.js index 4f7ddc2d..577ea24c 100644 --- a/jest.scripts.config.js +++ b/jest.scripts.config.js @@ -1,7 +1,7 @@ // scripts/ 전용 jest 설정. // // 왜 별도인가: package.json의 jest는 rootDir이 src라 scripts/ 아래 spec을 아예 -// 수집하지 않는다. 앱 커버리지 임계치(statements 96 등)에 빌드 도구를 섞고 싶지도 +// 수집하지 않는다. 앱 커버리지 임계치(statements 97 등)에 빌드 도구를 섞고 싶지도 // 않아서, 실행만 분리하고 커버리지 집계에서는 제외한다. module.exports = { rootDir: 'scripts', From adadfc972ded1a45aa924bf7f7ff123e0d946d62 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:51:43 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20=EB=B0=98=EC=A6=9D=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=9E=A1=20=ED=99=95=EC=9D=B8=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8(=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI(GITHUB_ACTIONS=true)에서만 실패하는 spec 1건 — test 샤드 실패 시 coverage-report·check가 failure로 끝나는지 PR CI에서 확인하고 바로 되돌린다 --- src/common/ci-falsification.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/common/ci-falsification.spec.ts diff --git a/src/common/ci-falsification.spec.ts b/src/common/ci-falsification.spec.ts new file mode 100644 index 00000000..9d5de259 --- /dev/null +++ b/src/common/ci-falsification.spec.ts @@ -0,0 +1,6 @@ +// 반증: 집계 잡 확인용 — 되돌림. CI(GITHUB_ACTIONS=true)에서만 실패해 test 샤드·coverage-report·check가 빨간색이 되는지 본다 +describe('반증: 집계 잡 확인용 — 되돌림', () => { + it('CI에서는 일부러 실패한다', () => { + expect(process.env.GITHUB_ACTIONS).toBeUndefined(); + }); +}); From e8499c1298f1d2caa721333c1c33ef8c83a3b64b Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:59:30 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20=EB=B0=98=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit adadfc972ded1a45aa924bf7f7ff123e0d946d62. - 반증 실행(run 37222298720) 결과: test (1) failure → coverage-report failure(merge-coverage가 테스트 실패로 exit 1, 댓글은 실패 내역으로 갱신) → check failure(skipped 아님) --- src/common/ci-falsification.spec.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/common/ci-falsification.spec.ts diff --git a/src/common/ci-falsification.spec.ts b/src/common/ci-falsification.spec.ts deleted file mode 100644 index 9d5de259..00000000 --- a/src/common/ci-falsification.spec.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 반증: 집계 잡 확인용 — 되돌림. CI(GITHUB_ACTIONS=true)에서만 실패해 test 샤드·coverage-report·check가 빨간색이 되는지 본다 -describe('반증: 집계 잡 확인용 — 되돌림', () => { - it('CI에서는 일부러 실패한다', () => { - expect(process.env.GITHUB_ACTIONS).toBeUndefined(); - }); -}); From 4d05aa0d8a8bad16e62269181d9c7d109a8412a2 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:59:56 +0900 Subject: [PATCH 7/8] =?UTF-8?q?ci:=20node=5Fmodules=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=ED=82=A4=EC=97=90=20.yarnrc.yml=C2=B7yarn=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 지적(Codex): .yarnrc.yml(nodeLinker·yarnPath)이나 .yarn/releases만 바뀌면 키가 같아 낡은 node_modules를 복원하고 설치를 건너뜀 - hashFiles에 '.yarnrc.yml', '.yarn/releases/**' 추가(5개 잡 동일), README·README.en 문구 갱신 - .yarn/patches는 lockfile의 patch 해시로 이미 키에 반영 테스트 - deploy-workflow.spec: 캐시 키에 yarn 설정·릴리즈가 들어가는지 고정 --- .github/workflows/pr-check.yml | 10 +++++----- README.en.md | 2 +- README.md | 2 +- scripts/deploy-workflow.spec.ts | 5 +++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 699ecbc8..acacca58 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -40,7 +40,7 @@ jobs: path: | node_modules .yarn/install-state.gz - key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} - name: Install dependencies if: steps.modules.outputs.cache-hit != 'true' @@ -99,7 +99,7 @@ jobs: path: | node_modules .yarn/install-state.gz - key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} - name: Install dependencies if: steps.modules.outputs.cache-hit != 'true' @@ -138,7 +138,7 @@ jobs: path: | node_modules .yarn/install-state.gz - key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} - name: Install dependencies if: steps.modules.outputs.cache-hit != 'true' @@ -195,7 +195,7 @@ jobs: path: | node_modules .yarn/install-state.gz - key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} - name: Install dependencies if: steps.modules.outputs.cache-hit != 'true' @@ -257,7 +257,7 @@ jobs: path: | node_modules .yarn/install-state.gz - key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json') }} + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} - name: Install dependencies if: steps.modules.outputs.cache-hit != 'true' diff --git a/README.en.md b/README.en.md index 754e2dda..6c6af120 100644 --- a/README.en.md +++ b/README.en.md @@ -424,7 +424,7 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ - `coverage-report`: merges the shard coverage, checks it against the `jest.config.js` thresholds (`scripts/merge-coverage.ts`) and uploads to Codecov. On a PR it also comments a comparison against the base branch - `check` treats skipped jobs as failures, because GitHub reports a skipped job as successful and that would satisfy a required check. - The comparison baseline is the artifact uploaded by develop/main push runs. It is only taken from successful push runs of the base branch in this repository; without one the comment shows no delta. -- `node_modules` is cached by `yarn.lock`, `package.json`, OS and Node version. On a hit only `prisma generate` runs instead of the install. +- `node_modules` is cached by `yarn.lock`, `package.json`, the Yarn config and release, OS and Node version. On a hit only `prisma generate` runs instead of the install. ### Flow diff --git a/README.md b/README.md index f1a09c10..9ee22137 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ - `coverage-report`: 샤드 커버리지를 합쳐 `jest.config.js`의 임계로 검사하고(`scripts/merge-coverage.ts`) Codecov에 올림. PR이면 base 브랜치 기준과 비교한 댓글을 남김 - `check`는 건너뛴 잡도 실패로 봅니다. GitHub는 건너뛴 잡을 성공으로 보고해 필수 체크를 통과시키기 때문입니다. - 커버리지 비교 기준은 develop·main push 실행이 올린 아티팩트입니다. 이 레포 base 브랜치의 성공한 push 실행에서만 받고, 없으면 차이 없이 표시합니다. -- `node_modules`는 `yarn.lock`·`package.json`·OS·node 버전을 키로 캐시합니다. 적중하면 설치 대신 `prisma generate`만 실행합니다. +- `node_modules`는 `yarn.lock`·`package.json`·yarn 설정과 릴리즈·OS·node 버전을 키로 캐시합니다. 적중하면 설치 대신 `prisma generate`만 실행합니다. ### 흐름 diff --git a/scripts/deploy-workflow.spec.ts b/scripts/deploy-workflow.spec.ts index 6c3d6208..c7e9d563 100644 --- a/scripts/deploy-workflow.spec.ts +++ b/scripts/deploy-workflow.spec.ts @@ -316,7 +316,7 @@ describe('pr-check.yml 잡 구성', () => { ); }); - it('의존성 캐시 키는 yarn.lock·package.json·OS·아키텍처·node 버전을 담고, 적중하면 설치 대신 prisma generate(postinstall)를 돈다', () => { + it('의존성 캐시 키는 yarn.lock·package.json·yarn 설정·릴리즈·OS·아키텍처·node 버전을 담고, 적중하면 설치 대신 prisma generate(postinstall)를 돈다', () => { const cached = Object.entries(wf.jobs).filter(([, job]) => job.steps.some((s) => s.uses?.startsWith('actions/cache@')), ); @@ -330,7 +330,8 @@ describe('pr-check.yml 잡 구성', () => { '${{ runner.os }}', '${{ runner.arch }}', '${{ steps.node.outputs.node-version }}', - "${{ hashFiles('yarn.lock', 'package.json') }}", + // .yarnrc.yml(nodeLinker·yarnPath)·yarn 릴리즈만 바뀌어도 낡은 설치를 복원하지 않게 + "${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }}", ]) expect(key).toContain(part); expect( From 2b150c156602961462e83481b76b51e43eb8c7b1 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 03:20:29 +0900 Subject: [PATCH 8/8] =?UTF-8?q?ci:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=83=A4=EB=93=9C=20=EB=B3=B4=EC=A1=B4?= =?UTF-8?q?=207=EC=9D=BC,=20check=20=EA=B6=8C=ED=95=9C=20=EB=B9=84?= =?UTF-8?q?=EC=9B=80,=20CI=20=EA=B5=AC=EC=A1=B0=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 샤드 아티팩트 보존 1일 → 7일: 하루 뒤 coverage-report만 재실행하면 샤드 0개로 실패하던 것 - check(집계)는 토큰을 쓰지 않아 permissions: {} - 가이드 §9·pre-push-test-plan 주석의 "CI check가 돌린다"를 static·scripts·test 잡 기준으로 --- .github/workflows/pr-check.yml | 3 ++- docs/guide/architecture-conventions.md | 2 +- scripts/pre-push-test-plan.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index acacca58..01cd109c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -221,7 +221,7 @@ jobs: with: name: coverage-shard-${{ matrix.shard }} path: coverage/report.json - retention-days: 1 + retention-days: 7 # 하루 뒤 coverage-report만 재실행해도 샤드가 남아 있게 overwrite: true # 잡 재실행 때 같은 이름 충돌 방지 # 샤드 결과를 합쳐 임계를 검사하고(테스트 실패·샤드 누락·임계 미달이면 실패) Codecov에 올린다. 테스트가 실패해도 돌아 @@ -345,6 +345,7 @@ jobs: needs: [static, scripts, build, test, coverage-report] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: {} steps: - name: Require every needed job to succeed diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 1c2ca95d..dc580e88 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -138,7 +138,7 @@ - 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 같은 이유로 CI도 jest를 샤드로 나눠 돌리되 샤드별 임계는 끄고(`'--coverageThreshold={}'`), `coverage-report` 잡이 샤드 결과를 합쳐(`scripts/merge-coverage.ts`) `jest.config.js`의 임계(statements 97 / branches 92 / functions 97 / lines 98)로 검사한다. 테스트 실패·샤드 누락·임계 미달이면 실패하고, 필수 체크 `check`가 이 결과를 모은다. `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. - ts-jest 변환 모드는 커버리지 여부로 갈린다(`jest.config.js`). `--coverage` 실행만 LanguageService 모드(`isolatedModules:false`)다 — transpile 모드의 데코레이터 메타데이터 가드식이 분기로 잡혀 branches 임계가 깨지기 때문이다. 나머지는 transpile 모드로, 파일별 타입 검사와 전이 의존 mtime 캐시 키가 없어 콜드 실행이 3배 이상 빠르다. 타입 오류는 jest가 아니라 `tsc --noEmit`(validate:push·CI)이 잡는다. -CI `check` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). +CI `static` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). **운영 호스트 보호.** 개발 머신이 운영 맥미니를 겸하고, testcontainers(MySQL·Redis)가 운영 컨테이너와 같은 OrbStack VM(4CPU·8GB)을 나눠 쓴다. 전체 jest(330스위트, 실DB 145개)를 pre-push마다 돌리던 시절 한 번에 5.7~12.7분이 걸렸고, 그동안 운영 응답이 느려지고 부하성 간헐 실패가 났다. 그래서 pre-push는 위처럼 범위를 좁히고 전체는 CI에 맡긴다. 맥미니에서 전체 테스트(`yarn validate`·`yarn test:cov`·경로 없는 `yarn test`)를 돌려야 하면 **한 번에 하나만** 돌린다(여러 세션·에이전트가 동시에 띄우지 않는다). jest는 globalSetup에서 호스트 락(127.0.0.1:47391 포트를 여는 것, 프로세스가 죽으면 OS가 푼다)을 잡아 같은 호스트의 실행을 하나로 줄 세우고, 로컬 워커는 4개로 제한한다(`jest.config.js`, 실측 근거는 주석). CI와 watch 모드는 락을 잡지 않는다. 부하 중에 난 간헐 실패는 결함으로 단정하기 전에 단독 재실행으로 확인한다. supertest로 부르는 앱은 `listenOnLoopback`(`src/test/http-app.ts`)으로 127.0.0.1에 먼저 연다 — 와일드카드로 열면 macOS에서 다른 프로세스가 같은 포트를 127.0.0.1로 잡아 응답을 가로챈다(`http-app.spec`이 반증과 사용처 전수를 고정). diff --git a/scripts/pre-push-test-plan.ts b/scripts/pre-push-test-plan.ts index ca2c48bc..0ac330d0 100644 --- a/scripts/pre-push-test-plan.ts +++ b/scripts/pre-push-test-plan.ts @@ -2,7 +2,7 @@ * pre-push 테스트 계획 — 기준 커밋 대비 변경으로 jest 범위(full·related·none)를 고른다. * * 왜: 전체 jest(실DB 스위트 포함)는 운영과 같은 맥미니에서 5.7~12.7분을 쓰고 운영 컨테이너와 - * CPU·메모리를 다툰다. 전체 회귀·커버리지는 CI `check`가 필수 체크로 이미 돌리므로, 로컬은 + * CPU·메모리를 다툰다. 전체 회귀·커버리지는 CI(test 샤드·coverage-report, 필수 체크 check가 모음)가 이미 돌리므로, 로컬은 * import 그래프로 닿는 spec만 돌리고 그래프가 못 보는 변경은 보수적으로 전체로 되돌린다. * * 사용: yarn test:push (yarn validate:push의 마지막 단계) @@ -57,7 +57,7 @@ const NONE_RULES: RegExp[] = [ /^(\.coderabbit\.yaml|codecov\.yml|spectaql\.yml|\.dependency-cruiser\.cjs)$/, ]; -// scripts/*.spec이 읽는 입력 — 이것이 바뀔 때만 push 전에 test:scripts를 돌린다(CI check는 항상 돌린다). +// scripts/*.spec이 읽는 입력 — 이것이 바뀔 때만 push 전에 test:scripts를 돌린다(CI scripts 잡은 항상 돌린다). // 운영 VM에 Grafana·Alloy·MySQL 컨테이너를 띄우고 push마다 약 24초를 쓰는데, 대부분의 기능 변경은 여기에 닿지 않는다. const SCRIPT_TEST_RULES: RegExp[] = [ /^scripts\//,