chore(main): release 1.53.0 #1775
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================== | |
| # GitHub Actions CI Pipeline | |
| # ============================================================== | |
| # 8 게이트: Contract Drift → Installer Lint → Jest → TypeScript Build → File Size Guard → ESLint → Routing Eval → Response Eval | |
| # 로컬 `npm test`/`npm run build`/`npm run lint` 와 같은 스크립트로 돈다(별도 미러 스크립트 없음). | |
| # Docker 사용 없음 (프로젝트 방침 영구 제외) | |
| # ============================================================== | |
| name: CI | |
| on: | |
| push: | |
| branches: [main, develop] | |
| pull_request: | |
| branches: [main, develop] | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| NODE_ENV: test | |
| JWT_SECRET: ci-test-secret-for-testing-only | |
| # 암호화 경로 테스트용 더미 키(hex 32B). 미설정이면 token-crypto 가 test 환경에서 no-op 이 | |
| # 되어 "secret 을 암호화한다" 류 테스트가 실패한다. **운영 키와 무관한 고정 더미값**이다. | |
| TOKEN_ENCRYPTION_KEY: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff' | |
| jobs: | |
| ci: | |
| name: CI Gate (Test → Build → Size → Lint) | |
| runs-on: ubuntu-latest | |
| # 15 → 25 (2026-08-01): 게이트 6종 총 소요가 13~15분으로 상한에 붙어, | |
| # 모든 게이트가 success 인데도 15분 02초에 job 이 cancelled 된 사례가 있었다(#421 1차). | |
| # 실제 실행 시간 단축(Jest 병렬화·게이트 분리)은 별도 과제. | |
| # 25 → 35 (2026-09-02): 총 소요가 21~24분(Jest 단독 ~19분)으로 다시 상한에 붙어 | |
| # 게이트 16단계 전부 success 인데 25분 00초에 cancelled 된 사례 재발(#711 1차). | |
| timeout-minutes: 35 | |
| # GITHUB_TOKEN 권한 명시 (PR 코멘트 게시용) | |
| # - contents: read → 체크아웃에 필요 | |
| # - pull-requests: write → PR 코멘트 작성/업데이트 | |
| # 주의: fork에서 올라온 PR은 보안상 항상 read-only 토큰만 발급됨 → sticky 코멘트 step이 403으로 silent skip됨 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: '.node-version' | |
| cache: 'npm' | |
| # 모노레포 npm workspaces — 루트 npm ci 한 번이면 모든 workspace 설치 + hoisting. | |
| # (apps/api 에서 중복 npm ci 하면 hoisting 이 깨져 web-scraper 의 playwright-core | |
| # dynamic import 가 tsc 에서 TS2307 로 실패함 — 루트 설치만 유지) | |
| - name: Install dependencies | |
| run: npm ci | |
| # ─── Gate 0: API Contract Drift ─── | |
| # packages/api-contracts 산출물이 TS SoT(swagger/spec-core·shared-types)와 일치하는지 검사. | |
| # 스펙 변경 후 `npm run contracts:export` 재생성을 누락한 커밋을 차단한다 (iOS 축 1 Step 5). | |
| # git diff 가 아니라 porcelain 인 이유: 신규 산출물 파일(미추적)도 drift 로 잡아야 함. | |
| # 수 초짜리 검사라 무거운 게이트들보다 앞에 둔다 (fail-fast, 기존 게이트 번호 유지 위해 Gate 0). | |
| - name: "Gate 0: API Contract Drift" | |
| run: | | |
| npm run contracts:export | |
| DRIFT=$(git status --porcelain -- packages/api-contracts) | |
| if [ -n "$DRIFT" ]; then | |
| echo "::error::계약 산출물 drift 감지 — 스펙(TS SoT) 변경 후 'npm run contracts:export' 를 실행해 산출물을 함께 커밋하세요" | |
| echo "$DRIFT" | |
| git diff -- packages/api-contracts | head -100 | |
| exit 1 | |
| fi | |
| echo "계약 산출물 최신 상태 확인" | |
| # ─── Gate 0.5: Installer Lint ─── | |
| # install.sh(47KB)·openmake_llm.sh 는 레포 파일 구조를 참조하는 배포 진입점인데 | |
| # 어떤 게이트도 검사하지 않아, 리팩터링이 인스톨러를 조용히 깨뜨려도 신규 설치자가 | |
| # 실패할 때까지 발견되지 않았다 (4축 배포 제품화 대조, 2026-08-22). bash -n(문법) + | |
| # shellcheck warning 이상 0건을 강제한다 — 도입 시점 클린 실측(지적 4건 전부 수정). | |
| # 전체 설치 완주는 별도 주간 워크플로우(install-smoke.yml — 러너 15분급이라 per-PR 과대). | |
| - name: "Gate 0.5: Installer Lint" | |
| run: | | |
| bash -n install.sh | |
| bash -n openmake_llm.sh | |
| shellcheck -S warning install.sh openmake_llm.sh | |
| echo "인스톨러 lint 통과 (bash -n + shellcheck)" | |
| # ─── Gate 1: Jest ─── | |
| # 로컬 `npm test` 와 **같은 러너**로 돈다. 이전에는 bun test 로 돌렸는데, 로컬은 jest 라 | |
| # 러너가 갈라져 있었다 — bun 은 jest.resetModules 등 일부 API 를 지원하지 않아 실측 시 | |
| # 12개가 러너 차이만으로 실패했다. 게다가 대상이 src/__tests__ **직속**(비재귀)이라 | |
| # 서브디렉토리·인라인 테스트 86개는 애초에 게이트 밖이었다. jest 로 통일해 전량을 본다. | |
| # | |
| # DB 가 필요한 테스트는 DATABASE_URL 부재 시 스스로 describe.skip 한다(러너에서 제외 불필요). | |
| - name: "Gate 1: Jest" | |
| run: npm test | |
| env: | |
| # supertest 반복 호출이 rate limit 에 걸리지 않도록 — jest.setup.ts 와 같은 축 | |
| CI: 'true' | |
| # ─── Gate 2: TypeScript Build ─── | |
| - name: "Gate 2: TypeScript Build" | |
| run: npm run build | |
| # ─── Gate 3: File Size Guard (max 600 lines) ─── | |
| - name: "Gate 3: File Size Guard" | |
| run: | | |
| MAX_LINES=600 | |
| VIOLATIONS="" | |
| while IFS= read -r f; do | |
| lines=$(grep -c '' "$f" 2>/dev/null || echo 0) | |
| if [ "$lines" -gt "$MAX_LINES" ]; then | |
| VIOLATIONS="$VIOLATIONS\n $f ($lines lines)" | |
| fi | |
| done < <(find apps/api/src -name '*.ts' -not -path '*/dist/*' -not -path '*__tests__*' -not -name '*.test.*' -not -name '*.d.ts' -not -name '*-locales.ts' -not -name '*-data-*.ts' -not -name '*-guidelines.ts' -not -name 'types.ts' -not -name 'runtime-limits.ts' -not -name 'prompt-templates.ts' -not -name 'language-policy.ts') | |
| if [ -n "$VIOLATIONS" ]; then | |
| echo "Files exceeding $MAX_LINES lines:$VIOLATIONS" | |
| exit 1 | |
| fi | |
| echo "All source files within $MAX_LINES line limit" | |
| # ─── Gate 4: ESLint ─── | |
| - name: "Gate 4: ESLint" | |
| run: npm run lint | |
| # ─── Gate 5: Routing Evaluation ─── | |
| # 키워드 라우터 정확도 회귀 검출. 베이스라인 50% 임계값 미만 시 차단. | |
| # eval:augmented는 Ollama embedding 호출 필요로 CI에서 제외 (수동/스테이징 실행) | |
| - name: "Gate 5: Routing Evaluation (Golden Dataset)" | |
| working-directory: apps/api | |
| env: | |
| OMK_EVAL_PASS_THRESHOLD: '0.5' | |
| run: npm run eval:routing | |
| # ─── Gate 6: Response Pattern Evaluation (mock) ─── | |
| # 응답 패턴 평가기 자체 동작 + mock generator 룰셋 회귀 검출. | |
| # --real 모드는 LLM 비용 발생으로 별도 워크플로우(미구현) | |
| - name: "Gate 6: Response Pattern Evaluation (mock)" | |
| working-directory: apps/api | |
| env: | |
| OMK_EVAL_RESPONSE_THRESHOLD: '0.5' | |
| run: npm run eval:response | |
| # 평가 결과 아티팩트 업로드 (PR 회귀 추적용) | |
| - name: Upload evaluation artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: evaluation-results-${{ github.sha }} | |
| path: apps/api/logs/*-evaluation-*.json | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| # ─── PR 평가 회귀 리포트 코멘트 (sticky) ─── | |
| # PR 이벤트일 때만 동작. Routing/Response 평가 JSON에서 통과율을 읽어 마크다운 표 생성. | |
| # actions/github-script(@v7, GitHub 공식)로 PR 코멘트 listComments → marker 검색 → update or create. | |
| # if: always() — 평가 게이트가 실패(exit 1)해도 회귀 리포트는 게시되어야 한다 (회귀 가시성 확보). | |
| - name: "Post evaluation pass-rate comment (PR only)" | |
| if: always() && github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| env: | |
| ROUTING_BASELINE: ${{ env.OMK_EVAL_PASS_THRESHOLD || '0.5' }} | |
| RESPONSE_BASELINE: ${{ env.OMK_EVAL_RESPONSE_THRESHOLD || '0.5' }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const LOGS_DIR = 'apps/api/logs'; | |
| // 동일 PR에 코멘트가 누적되지 않도록 식별용 HTML 마커 | |
| const MARKER = '<!-- eval-regression-report -->'; | |
| const commit = (context.sha || '').slice(0, 7); | |
| // 가장 최근의 평가 JSON 파일 한 개를 prefix 기준으로 찾는다. | |
| // - prefix 'evaluation-' 만 매치하기 위해 'response-evaluation-' 은 제외 (Gate 5 결과) | |
| // - prefix 'response-evaluation-' 은 Gate 6 결과 | |
| // 파일이 없으면 null 반환하여 행을 누락 처리 (코멘트는 그래도 게시). | |
| function findLatest(prefix, exclude) { | |
| if (!fs.existsSync(LOGS_DIR)) return null; | |
| const candidates = fs.readdirSync(LOGS_DIR) | |
| .filter((f) => f.startsWith(prefix) && f.endsWith('.json')) | |
| .filter((f) => !exclude || !f.startsWith(exclude)) | |
| .map((f) => { | |
| const full = path.join(LOGS_DIR, f); | |
| return { full, mtime: fs.statSync(full).mtimeMs }; | |
| }) | |
| .sort((a, b) => b.mtime - a.mtime); | |
| return candidates[0]?.full || null; | |
| } | |
| function loadEval(file) { | |
| if (!file) return null; | |
| try { | |
| return JSON.parse(fs.readFileSync(file, 'utf8')); | |
| } catch (err) { | |
| core.warning(`평가 JSON 파싱 실패: ${file} — ${err.message}`); | |
| return null; | |
| } | |
| } | |
| function fmtPct(rate) { | |
| if (typeof rate !== 'number') return 'n/a'; | |
| return `${(rate * 100).toFixed(1)}%`; | |
| } | |
| const routingFile = findLatest('evaluation-', 'response-evaluation-'); | |
| const responseFile = findLatest('response-evaluation-', null); | |
| const routing = loadEval(routingFile); | |
| const response = loadEval(responseFile); | |
| const routingBaseline = parseFloat(process.env.ROUTING_BASELINE || '0.5'); | |
| const responseBaseline = parseFloat(process.env.RESPONSE_BASELINE || '0.5'); | |
| function row(label, data, baseline) { | |
| if (!data) { | |
| return `| ${label} | n/a | n/a | ${fmtPct(baseline)} | 결과 파일 없음 |`; | |
| } | |
| const passed = data.passRate >= baseline ? '✅' : '❌'; | |
| return `| ${label} | ${fmtPct(data.passRate)} | ${data.passedCases}/${data.totalCases} | ${fmtPct(baseline)} | ${passed} |`; | |
| } | |
| const body = [ | |
| MARKER, | |
| `## 평가 회귀 리포트 (\`${commit}\`)`, | |
| '', | |
| '| 평가 | 통과율 | 통과/전체 | 베이스라인 | 상태 |', | |
| '|---|---|---|---|---|', | |
| row('Routing (Gate 5)', routing, routingBaseline), | |
| row('Response (Gate 6)', response, responseBaseline), | |
| '', | |
| `- 데이터셋 버전: routing=\`${routing?.datasetVersion ?? 'n/a'}\`, response=\`${response?.datasetVersion ?? 'n/a'}\``, | |
| `- 베이스라인은 \`OMK_EVAL_PASS_THRESHOLD\` / \`OMK_EVAL_RESPONSE_THRESHOLD\` 환경변수 기준 (이력 비교 아님)`, | |
| `- 상세 결과: Actions 실행의 \`evaluation-results-${context.sha}\` 아티팩트 참조`, | |
| ].join('\n'); | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| // marker가 포함된 기존 코멘트를 찾아 update; 없으면 create. 페이지네이션 처리. | |
| const existing = await github.paginate( | |
| github.rest.issues.listComments, | |
| { owner, repo, issue_number, per_page: 100 }, | |
| ); | |
| const previous = existing.find((c) => c.body && c.body.includes(MARKER)); | |
| if (previous) { | |
| await github.rest.issues.updateComment({ | |
| owner, repo, comment_id: previous.id, body, | |
| }); | |
| core.info(`평가 회귀 코멘트 업데이트 완료: comment_id=${previous.id}`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number, body, | |
| }); | |
| core.info('평가 회귀 코멘트 신규 생성 완료'); | |
| } |