diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 2f7bae8..15c172d 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,13 +10,16 @@ permissions: contents: read concurrency: - # PR push는 같은 PR의 이전 실행을 취소, main/develop push는 커밋별 독립 실행 + # PR push는 같은 PR의 이전 실행을 취소, main/develop push는 커밋별 독립 실행(기준 커버리지 아티팩트 유실 방지) group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + # test 잡 matrix.shard와 함께 바꾼다 — coverage-report가 blob 개수를 이 값과 대조한다 + SHARD_COUNT: 3 + jobs: - # 로컬 validate(pre-push)와 같은 순서. 보호된 check status라 --no-verify·훅 미설치를 우회하지 못한다 - check: + lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -38,42 +41,103 @@ jobs: - name: Lint run: pnpm lint - - name: Type check - run: pnpm typecheck - - - name: Dead code / unused deps (knip) - run: pnpm knip + # build가 tsc -b를 돌리므로 별도 typecheck 단계는 두지 않는다 + static: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Test with coverage - run: pnpm test:cov + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - # CODECOV_TOKEN이 레포 시크릿에 있을 때만 올린다 — 등록 전에는 건너뛰고, 등록 뒤 실패는 CI 실패로 드러낸다 - - name: Upload coverage to Codecov - if: env.CODECOV_TOKEN != '' - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + - name: Setup Node.js (24.x) & pnpm cache + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info - disable_search: true - name: admin-fe-lcov - fail_ci_if_error: true + node-version: '24.x' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Codegen freshness + run: pnpm codegen:check + + - name: Dead code / unused deps (knip) + run: pnpm knip - name: Build run: | pnpm build test -f dist/index.html - # PR 코멘트에 커버리지 표. check와 별도 잡이라 필수 체크 이름이 안정적이다 + # 샤드는 커버리지 일부만 가져 임계를 0으로 끈다. 임계는 coverage-report가 합친 결과로 검사한다 + test: + name: test (${{ matrix.shard }}/${{ strategy.job-total }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node.js (24.x) & pnpm cache + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.x' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test shard with coverage + env: + SHARD: ${{ matrix.shard }} + run: >- + pnpm exec vitest run --coverage --shard="$SHARD/$SHARD_COUNT" + --reporter=default --reporter=blob --outputFile.blob="blob-report/blob-$SHARD.json" + --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 + --coverage.thresholds.branches=0 --coverage.thresholds.statements=0 + + # 기본 경로(.vitest/blob)는 숨김 폴더라 upload-artifact가 건너뛴다 + - name: Upload blob report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blob-${{ matrix.shard }} + path: blob-report/ + # 실패한 샤드도 올리므로 재실행 때 같은 이름이 생긴다(덮어쓰지 않으면 업로드 실패로 빨간불이 이어짐) + overwrite: true + retention-days: 7 + if-no-files-found: error + + # 필수 체크라 always()로 돈다 — needs 실패로 건너뛰면(skipped) 통과로 잡힌다 coverage-report: - if: github.event_name == 'pull_request' + needs: test + if: always() runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read pull-requests: write + actions: read steps: + - name: Require all test shards passed + env: + TEST_RESULT: ${{ needs.test.result }} + run: | + if [ "$TEST_RESULT" != "success" ]; then + echo "test 샤드 결과: $TEST_RESULT" + exit 1 + fi + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -89,12 +153,98 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Test with coverage - run: pnpm test:cov + - name: Download blob reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: blob-* + merge-multiple: true + path: blob-report + + # 빠진 샤드가 있으면 부분 커버리지가 된다. 임계(vitest.config.ts)는 합친 결과에 적용된다 + - name: Merge shard reports (coverage thresholds) + run: | + count=$(find blob-report -name 'blob-*.json' | wc -l) + if [ "$count" -ne "$SHARD_COUNT" ]; then + echo "blob ${count}개, 기대 ${SHARD_COUNT}개" + exit 1 + fi + pnpm exec vitest run --merge-reports=blob-report --coverage + + # develop·main push의 요약이 이후 PR의 비교 기준이 된다. 업로드 실패가 배포를 막지 않게 둔다 + - 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-summary + path: | + coverage/coverage-summary.json + coverage/coverage-final.json + overwrite: true + retention-days: 90 + if-no-files-found: error + + # CODECOV_TOKEN이 레포 시크릿에 있을 때만 올린다 — 등록 전에는 건너뛰고, 등록 뒤 실패는 CI 실패로 드러낸다 + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + disable_search: true + name: admin-fe-lcov + fail_ci_if_error: true + + # 기준은 이 레포 base 브랜치의 성공한 push 실행만 쓴다(PR 실행 아티팩트는 믿지 않음). base 커밋 실행 우선, 그다음 최신 순으로 + # 아티팩트가 있는 실행을 최대 5개까지 찾는다. 임계 미달로 병합이 실패해도 요약이 있으면 댓글을 갱신한다 + - name: Fetch base coverage (PR) + id: base + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/coverage-summary.json') != '' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.base_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + run_ids=$(gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?branch=$BASE_REF&event=push&status=success&per_page=50" \ + | jq -r --arg repo "$REPO" --arg ref "$BASE_REF" --arg sha "$BASE_SHA" ' + [.workflow_runs[] | select(.event == "push" and .head_branch == $ref and .head_repository.full_name == $repo)] + | (map(select(.head_sha == $sha)) + .) | map(.id) + | reduce .[] as $id ([]; if any(.[]; . == $id) then . else . + [$id] end) + | .[:5][]') + for run_id in $run_ids; do + if gh run download "$run_id" -R "$REPO" -n coverage-summary -D base-coverage \ + && [ -f base-coverage/coverage-summary.json ]; then + echo "기준 실행: $run_id" + echo "summary=base-coverage/coverage-summary.json" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "기준 아티팩트 없음 — 비교 없이 리포트" - - name: Coverage report (vitest) - if: always() + - name: Coverage report (PR comment) + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/coverage-summary.json') != '' }} uses: davelosert/vitest-coverage-report-action@c4bbc33a89b7ace0e63d35f1f7d4bcee31155a73 # v2.13.0 + with: + json-summary-compare-path: ${{ steps.base.outputs.summary }} + + # 필수 체크 집계. 건너뜀·취소도 실패로 본다 — 보호된 status라 --no-verify·훅 미설치를 우회하지 못한다 + check: + if: always() + needs: [lint, static, test, coverage-report] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Require all jobs succeeded + env: + NEEDS: ${{ toJSON(needs) }} + run: | + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + echo "$NEEDS" | jq -e 'all(.[]; .result == "success")' > /dev/null pr-title: if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 96b739f..0c0cc11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules /dist /coverage +# vitest blob(기본 .vitest/blob, CI 샤드는 blob-report/) +.vitest/ +blob-report/ *.tsbuildinfo .pnpm-store diff --git a/.husky/pre-push b/.husky/pre-push index 56bd684..3f2eb5f 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,2 @@ -# push 전 로컬 검증 — CI check와 같은 순서. --no-verify 금지 +# push 전 로컬 검증 — CI의 lint·static·test·coverage-report가 나눠 하는 검사를 한 번에. --no-verify 금지 pnpm validate diff --git a/README.md b/README.md index d2bc932..510b36b 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ pnpm dev # http://localhost:5173 — /graphql·/auth는 localhost:400 ## 명령어 -| 명령 | 내용 | -| ----------------------------- | --------------------------------------------------------------------------- | -| `pnpm validate` | lint → typecheck → knip → 테스트(커버리지) → 빌드. pre-push 훅과 동일합니다 | -| `pnpm test` / `pnpm test:cov` | Vitest | -| `pnpm lint` / `pnpm format` | ESLint(경계 규칙 포함) / Prettier | -| `pnpm build` / `pnpm preview` | 운영 빌드 / 로컬 미리보기 | +| 명령 | 내용 | +| ----------------------------- | ------------------------------------------------------------------------------------------- | +| `pnpm validate` | lint → typecheck → codegen:check → knip → 테스트(커버리지) → 빌드. pre-push 훅과 동일합니다 | +| `pnpm test` / `pnpm test:cov` | Vitest | +| `pnpm lint` / `pnpm format` | ESLint(경계 규칙 포함) / Prettier | +| `pnpm build` / `pnpm preview` | 운영 빌드 / 로컬 미리보기 | ## 배포 diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 039fe81..249e2b0 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -67,7 +67,7 @@ src/ - `*.spec.ts(x)`를 소스 옆에. `it`은 한국어 평서형. - 네트워크는 MSW로 계약 기반 mock(응답 모양은 codegen 타입을 따른다). fetch를 직접 stub하지 않는다. -- 커버리지 임계는 `vitest.config.ts`. shadcn 복사본(`src/shared/ui`)·codegen 산출물·라우트 트리는 제외. +- 커버리지 임계는 `vitest.config.ts`. shadcn 복사본(`src/shared/ui`)·codegen 산출물·라우트 트리는 제외. CI는 샤드별 임계를 끄고 합친 결과로 검사한다(샤드 하나는 일부 커버리지만 가진다). ## 8. 명령어와 게이트 @@ -78,5 +78,10 @@ pnpm schema:pull [ref] # BE SDL 스냅샷 갱신(기본 main). BE_DIR=../caqui pnpm codegen # 스냅샷 + 문서 → src/graphql/generated (커밋 대상, CI가 codegen:check로 신선도 검사) ``` +- CI(`pr-check.yml`)는 잡을 나눠 병렬로 돈다. 필수 체크 `check`는 아래 잡이 모두 `success`인지 집계한다(건너뜀·취소도 실패). + - `lint` + - `static`: `codegen:check` → `knip` → `build`(`tsc -b` 포함이라 typecheck 단계는 따로 없다) + - `test`: Vitest 3샤드, 결과는 blob 아티팩트로 넘긴다 + - `coverage-report`: blob을 합쳐 임계 검사 → Codecov → PR 댓글. 비교 기준은 base 브랜치(develop·main) push 실행이 올린 커버리지 요약 - 커밋은 Conventional Commits + 한국어 본문(commitlint). 브랜치는 `/<대상>`. - PR 본문에 `## 플랜 대조` 표. 봇 리뷰(Codex·CodeRabbit)는 BE와 같은 절차로 처리한다. diff --git a/docs/guide/decisions.md b/docs/guide/decisions.md index a93b412..f5b6272 100644 --- a/docs/guide/decisions.md +++ b/docs/guide/decisions.md @@ -10,7 +10,7 @@ | D4 | 2026-09-27 | TanStack Query + graphql-request + graphql-codegen(client-preset) | 정규화 캐시 없이 invalidate로 충분한 CRUD 화면. 서버 진실 우선, 낙관적 업데이트 없음 | | D5 | 2026-09-27 | Tailwind v4 + shadcn/ui(new-york, neutral) + TanStack Table + react-hook-form + zod, 차트 Recharts | FE-v2와 같은 스타일 체계. 컴포넌트는 레포가 소유(`src/shared/ui`) | | D6 | 2026-09-27 | 디자인 토큰은 FE-v2 계승(primary #7c5cff 계열, gray 스케일, status 3색, radius 10px, Pretendard), 라이트/다크 | 서비스와 같은 브랜드 인상, 장시간 사용 도구라 다크 필요 | -| D7 | 2026-09-27 | Vitest + Testing Library + MSW. E2E 없음. 커버리지 lines/statements 80 · branches 70 · functions 80, Codecov patch 80 | 관리자 도구 범위에 맞는 비용. shadcn 복사본은 커버리지 제외 | +| D7 | 2026-09-27 | Vitest + Testing Library + MSW. E2E 없음. 커버리지 lines/statements 80 · branches 70 · functions 80(대체됨 → D16), Codecov patch 80 | 관리자 도구 범위에 맞는 비용. shadcn 복사본은 커버리지 제외 | | D8 | 2026-09-27 | 배포: 이 레포가 Dockerfile(nginx) · `infra/compose.yml` · `deploy.yml` 소유. GHCR `ghcr.io/caquick/caquick-admin-fe:`(arm64), 맥미니 셀프호스트 러너, BE compose 네트워크 `caquick_default`에 external 참여, 기존 cloudflared가 라우팅 | BE와 독립 배포·롤백. BE 레포 변경은 Tunnel 호스트 1줄 | | D9 | 2026-09-27 | 도메인 `admin.caquick.site` → 컨테이너 `caquick-admin:80`. API `https://api.caquick.site` | 같은 부모 도메인이라 refresh 쿠키가 same-site로 전송된다 — 쿠키 도메인 변경 불필요, BE는 CORS 오리진만 추가 | | D10 | 2026-09-27 | 인증: REST `/auth/admin/*`. accessToken은 메모리, refresh는 httpOnly 쿠키. 401이면 refresh 1회 후 재시도, 부팅 시 refresh로 복원, `mustChangePassword`면 변경 화면 강제 | 토큰을 localStorage에 두지 않는다(XSS 반경). 로컬은 Vite 프록시로 same-origin | @@ -19,3 +19,4 @@ | D13 | 2026-09-27 | BE SDL 스냅샷(`schema/schema.graphql`) 커밋 + `pnpm schema:pull`. CI는 codegen 신선도만 게이트, BE main과의 drift는 advisory | CI가 BE 체크아웃에 의존하지 않게 | | D14 | 2026-09-27 | 문서: `CLAUDE.md`·`.claude/`·`AGENTS.md`·`docs/*`(guide 제외)·`.figma/` gitignore. `docs/guide/`·README 커밋 | BE와 같은 방식 | | D15 | 2026-09-27 | 의존 방향은 ESLint boundaries로 강제(shared→features 금지, feature 간은 index.ts만). 검사기는 반증 케이스로 확인하고 넣는다 | BE `arch:check`와 같은 역할 | +| D16 | 2026-10-05 | 커버리지 임계를 실측 정수 내림으로 상향: statements 95 · branches 88 · functions 94 · lines 96. CI는 테스트를 3샤드로 나누고 coverage-report가 합친 결과로 검사 | 실측(95.54·88.07·94.29·96.07)보다 한참 낮은 임계는 회귀를 못 잡는다. 샤드 합산이 단일 실행과 같음을 확인 | diff --git a/schema/schema.graphql b/schema/schema.graphql index 0466de5..36852f3 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@251a957ea22bb84be535aa3d6cb8a4214e5e585b 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@746d6fa87c2438aeef9afd739885f33274e1f021 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- diff --git a/vitest.config.ts b/vitest.config.ts index 77638ee..68098c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,8 @@ export default defineConfig({ 'src/main.tsx', 'src/**/*.d.ts', ], - thresholds: { lines: 80, statements: 80, branches: 70, functions: 80 }, + // 실측(샤드 합산 = 단일 실행)의 정수 내림. CI는 coverage-report가 합친 결과로 검사한다 + thresholds: { lines: 96, statements: 95, branches: 88, functions: 94 }, }, }, });