PR 전 CI 로컬 선실행 — CI 11분×재시도 9회에서 npm run ci:local 한 명령으로 (2026-09-04)
- 기간: 2026-09-04 (세션 2개 — 분석·계획
c39c3fbc, 구현b59a92ee. 오너 지시 "CI 왜 이렇게 많이 실패해? 미리미리 잘 체크 좀 해" → "#1225 이어서 해줘 … 질문 말고 Phase 끝까지 완수") - 랜딩: PR #1230(Phase 1~4, squash 머지) — 마이그레이션·엣지 없음, Vercel 배포 없음(스크립트·문서·테스트 전용)
- 설계서: 이슈 #1225 본문("예상 효과·개선사항" 절 포함)
- 정본:
scripts/ci-local.mjs(명령),docs/process/ci-local.md(절차), 판정 코드는 CI와 공유하는scripts/ci-scope-policy.mjs - 도구:
npm run ci:local(레포 안). 샌드박스는 레포 밖 임시 폴더barbelic-ci-local/<체크아웃 해시>(자동 생성) - 게이트:
tests/react/ciLocal.test.mjs— 워크플로 원문(policy-contract.yml)의npm run순서·supabase start -x목록·핀 이미지·reset 옵션과 스크립트 상수를 대조 + ci:local이 남기는 preflight 기록이 잠금 판정(evaluateRecord)을 통과하는지 (npm run check에 포함). 이 트랙 자체는 마이그레이션·pgTAP·e2e 파일을 만지지 않았다(로컬 pgTAP은 도구 검증으로 100파일·1,601 assert 통과, 아래 5절) - 버그리포트: 없음(절차 도구 신설)
- 계약:
docs/process/migration-landing.md도구 표·0단계·0-1단계에ci:local추가. 같은 날 랜딩한 #1224(PR #1228)의 랜딩 잠금 preflight 기록(.git/landing-preflight.json)을 ci:local이 pgTAP 통과 시 같은 형식으로 남긴다
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 조사 — CI 잡 구성·최근 실패 실측·로컬에서 안 돌리게 되는 이유 | ✅ 이슈 본문 |
| Phase 1 | npm run ci:local 뼈대 + CI 범위 판정 + verify 묶음 | ✅ PR #1230 |
| Phase 2 | 샌드박스 스택 자동화 (레포 밖 config·junction·빈 포트 선정·Docker 안내·CRLF 검사) | ✅ PR #1230 |
| Phase 3 | e2e 5묶음 + 결과 요약(PR 본문용 한 줄) + #1223 실패 재현 확인 | ✅ PR #1230 |
| Phase 4 | 절차 정본화 — docs/process/ci-local.md, 사용자 CLAUDE.md §20 교체, 작업 기록 | ✅ PR #1230 |
1. 배경
CI(.github/workflows/policy-contract.yml)는 verify(검사·빌드, 4분)와 migration-smoke(로컬 Supabase 기동 → 마이그레이션 전체 적용 → pgTAP → node e2e 3개 → 브라우저 저니 → 뷰포트 매트릭스, 11분) 두 묶음이다. 2026-09-03~04에 랜딩한 PR #1219(#1200)와 #1223(#1202)에서 CI가 합쳐 9번 빨간불이 났는데 앱 결함은 0건이었고 전부 pgTAP·e2e 쪽이었다. 오너: "CI 왜 이렇게 많이 실패해? 미리미리 잘 체크 좀 해."
2. 문제 제기
CI가 하는 일은 전부 이 PC에서 돌릴 수 있는데, 한 번에 도는 명령이 없었다
로컬 Supabase 샌드박스 절차는 있었지만(메모리·CLAUDE.md §20) 명령이 8~10개였다: 샌드박스 폴더 → config.toml 작성(다른 project_id·포트) → migrations·tests junction → supabase start -x … → db reset → test db → supabase status -o env로 키 뽑기 → e2e마다 환경변수 넣어 실행. 세션마다 손으로 조립하다가 "로컬 미실행 — CI 몫"으로 넘기고 CI를 첫 실행으로 썼다.
CI가 어떤 e2e를 어떤 환경변수로 돌리는지가 워크플로 파일 안에만 있었다
그래서 로컬 실행이 CI와 조금씩 어긋났다(예: 조회 열 누락으로 로컬은 통과·CI는 NaN). 판정(verify-only/full)도 로컬에는 없었다.
실측 (gh run list, PR #1223)
| CI 실행 | 결과 | 실패 잡·단계 |
|---|---|---|
| 5회 | 실패 5회 | 4회 = migration-smoke "Exercise database write contracts"(pgTAP: calendar_top_sets_effective_load·composite_set_score — "Stats integrity validation failed"), 1회 = 브라우저 저니 |
| 6회째 | 성공 | — |
3. 해결 방안
원칙 (오너 결정)
- D1 (2026-09-04, CLAUDE.md §20): "서버 테스트(pgTAP·e2e 로컬 프로필)는 PR 전에 로컬 샌드박스에서 먼저 돌린다 — CI는 확인용이지 첫 실행이 아니다."
- D2 (2026-09-04, "#1225 이어서 해줘 … 질문 말고 Phase 끝까지"): 이슈 본문 Phase 계획대로 완주.
접근
| 대안 | 판단 |
|---|---|
CI 명령을 그대로 재현하는 스크립트 npm run ci:local (채택) | CI와 같은 판정 코드·같은 순서·같은 환경변수. Windows Docker 함정을 스크립트가 흡수 |
| nektos/act로 GitHub Actions를 통째로 로컬 실행 | CI가 Docker로 Supabase를 띄우므로 Windows에서 Docker 안의 Docker — 실용성 없음. 기각 |
| CI를 잘게 쪼개 빠른 잡만 먼저 보기 | 대기 시간은 줄지만 재시도 횟수는 그대로. 기각 |
4. 적용한 내용
Phase 1 — 뼈대 + 판정 + verify (PR #1230)
scripts/ci-local.mjs신설,package.json에ci:local. 바뀐 파일 =git diff --no-renames --name-only --diff-filter=ACMRTD <기준>+ 미추적 파일(커밋 전 작업물까지), 판정은 CI와 같은scripts/ci-scope-policy.mjs의fullCiPaths.--plan으로 판정·계획만 출력,--full·--verify-only·--only <단계>·--base.- verify 묶음을 CI와 같은 순서(
check:policy-snapshots → check:legal-documents → check → check:unused → build)로, 기준 커밋을POLICY_BASE_REF·LEGAL_BASE_REF·MIGRATION_BASE_REF에 넣어 실행.
Phase 2 — 샌드박스 스택 자동화 (PR #1230)
- 위치
os.tmpdir()/barbelic-ci-local/<체크아웃 경로 sha1 8자>. 체크아웃(워크트리)마다 다른 폴더·project_id(cil<해시>)·포트(55000 + 해시%40×100 + 21/22/23부터 빈 자리 탐색)라 동시 세션이 서로의 DB를 지우지 않는다. 레포 안supabase/config.toml은 건드리지 않는다. supabase/migrations·supabase/tests는 체크아웃으로 junction(fs.symlinkSync(…, "junction"), 제거는 비재귀rmdirSync만 — 재귀 삭제가 본체를 지우는 사고 방지).- 사전 점검:
supabase --version,docker info(실패 시 이 PC의 소켓 잔존 우회법을 쉬운 말로 안내),supabase/migrations/*.sqlCRLF 검사(있으면.git/info/attributes처방 안내). 종료 코드 3. supabase start -x <CI와 같은 목록>→ 이미 떠 있으면 재사용 →db reset --local --no-seed→supabase test db→supabase status -o env에서 URL·키 추출.- pgTAP 통과 시 #1224의 랜딩 잠금 preflight 기록(
scripts/migrations/preflight-record.mjs의writeRecord, 브랜치·HEAD·서버 파일 해시·파일 수·assert 수)을 남기고, 시작 전에 이전 기록을 지운다 —npm run db:preflight와 같은 규칙. 샌드박스 기본 위치도 같은 환경변수BARBELIC_DB_SANDBOX.
Phase 3 — e2e + 요약 (PR #1230)
- CI와 같은 환경변수(
E2E_SUPABASE_URL·E2E_SUPABASE_ANON_KEY·E2E_SUPABASE_SERVICE_ROLE_KEY)로test:e2e-local → test:e2e-empty → test:e2e-cardio → test:e2e-browser → test:e2e-viewport. 브라우저 두 단계는CI=true로 돌려 재시도 1회·플레이키 실패·기존 4173 서버 재사용 금지까지 CI와 같다(4173 점유 시 종료 코드 3). - 결과 파싱: pg_prove 총계(
Files=·Tests=)·실패 파일, node 테스트 리포터(spec·TAP 둘 다)·실패 이름, Playwright JSON 리포트(stats+ 실패 spec 제목). 마지막에 사람용 요약 + PR 본문용 한 줄(검증: ci:local full · verify 통과 … · 총 소요). tests/react/ciLocal.test.mjs: 워크플로 원문과 스크립트 상수 대조 + 파서 6종.
Phase 4 — 절차 정본화 (PR #1230)
docs/process/ci-local.md(명령·옵션·CI 대응 표·샌드박스·실패 읽는 법·종료 코드 3 대처 표), 사이드바 등록.docs/process/migration-landing.md도구 표·0단계에 추가.- 사용자 CLAUDE.md §20의 손 절차를
npm run ci:local한 줄로 교체(레포 밖).
주요 결정과 그 근거
- 판정 코드를 복제하지 않고 CI의
ci-scope-policy.mjs를 import — 규칙이 바뀌면 자동으로 같이 바뀐다. - 단계 목록은 상수로 노출하고 테스트가 워크플로 원문과 대조 — 워크플로만 고치고 스크립트를 안 고치면
npm run check가 빨개진다. - verify 실패 시 DB 묶음은 계속 돈다 — CI에서 두 잡이 병렬인 것과 같게, 로컬 한 바퀴로 전부 본다. e2e는
db reset·pgTAP 실패 시 건너뛴다. - 브라우저 저니에
CI=true— 로컬 기본값(재시도 0·기존 서버 재사용)이면 CI와 판정이 달라진다.
작업 중 드러난 것
- 같은 날 같은 문제를 다른 각도에서 푼 트랙 #1224(PR #1228, 랜딩 잠금이 로컬 pgTAP 통과 기록을 요구)가 먼저 랜딩해 PR #1230이 충돌했다. 두 도구가 따로 놀지 않게 ci:local이 그 기록을 같은 형식으로 남기도록 통합했다(위 Phase 2). 리베이스 충돌은 사이드바·README 등록 줄 두 곳뿐.
- Node 22의
node --test는 파이프로 받아도 spec 리포터(ℹ pass 11)를 쓴다 — TAP만 파싱하면 0/0이 된다. 둘 다 읽게 했다. spawnSync("supabase", ["--version"], {shell:true})는 DEP0190 경고 — 명령 문자열 하나로 넘겨야 한다.- 로컬 Postgres 이미지
public.ecr.aws/supabase/postgres:17.6.1.158(CLI 2.113.0) ≠ CI 핀ghcr.io/…:17.6.1.156(CLI 2.111.0). 스크립트는 참고 로그만 남긴다. supabase start가 끝에 로컬 데모 키 JSON을 stdout에 찍는다(CI도 같음).
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 판정 | 변경 없음 → verify-only / package.json·supabase/ 파일 변경 → full(근거 파일 표시) / --full 강제 / --only 부분 실행 — --plan으로 확인 |
| verify 묶음 (로컬) | 5단계 통과, 1분 26초 (CI verify 잡 4분) |
| 샌드박스 빈 상태 기동 | supabase start 약 2분 → db reset 1분 14초 → pgTAP 100파일/1601 assert 36초 — 총 3분 52초 |
| 샌드박스 재사용 | 두 번째 실행에서 "이미 떠 있다 — 재사용", 기동 2초 |
| e2e-local / empty / cardio | 11/11(1 skip) · 7/7(1 skip) · 6/6 통과 |
| e2e-browser | 29/29 통과 (CI=true, 4분 2초) |
| e2e-viewport | 13/13 통과 (1분 22초) |
| full 한 바퀴 총 소요 | verify 1분 26초 + DB 3분 52초(첫 기동 포함) + e2e 5분 38초 = 약 11분 (세 묶음을 따로 돌린 합산. 스택 재사용 시 DB 묶음은 약 2분) |
| #1223 실패 재현 | 첫 실패 커밋 49490911을 별도 워크트리·별도 샌드박스에서 --full --only db → 재현됨: CI와 같은 pgTAP 실패(calendar_top_sets_effective_load·composite_set_score_v1 "stats projection did not settle / Stats integrity validation failed" 등 5파일), 3분 22초. CI 11분 대기 없이 같은 실패를 봤다 |
| 동시 실행 | 두 워크트리의 샌드박스가 다른 포트(58221/58222 · 57421/57422)로 같은 시간에 떠서 서로 간섭 없음 |
| CI | 생략 — 스크립트·테스트·문서 전용, CI 잡이 실행 경로로 검증하는 것 없음(§19). 로컬 npm run check 통과 |
6. 이번 개선으로 향상된 것
PR 전에 CI 결과를 안다
CI 11분을 기다려 첫 실패를 알던 것이 → PR 열기 전 로컬 한 바퀴(verify 1.5분 + DB 4분 + e2e 5.5분)에서 같은 판정으로 안다. 줄어드는 것은 CI 시간이 아니라 CI 재시도 횟수다(#1223 6회 → 목표 1~2회).
로컬과 CI가 같은 것을 본다
판정 코드·단계 순서·환경변수가 하나의 정본(스크립트 + 워크플로 대조 테스트)이 됐다. "로컬은 통과·CI만 실패" 부류(환경변수 누락)가 구조적으로 사라진다.
구조적으로 남는 것
npm run ci:local한 명령과docs/process/ci-local.md절차.- 워크플로 ↔ 스크립트 대조 게이트(
tests/react/ciLocal.test.mjs). - PR 본문 "검증" 줄의 고정 형식(
검증: ci:local …).
남은 것
- 로컬 Postgres 이미지와 CI 핀의 패치 버전 차이(17.6.1.158 vs .156) — 결과가 어긋날 때 CLI 버전을 맞추는 것으로 대응, 지금은 참고 로그만.
- 브라우저 저니 포트 4173 고정 — 다른 세션의 미리보기 서버와 겹치면 종료 코드 3으로 멈춘다(Playwright 설정의 포트가 고정이라 스크립트가 바꿀 수 없음).