Skip to content

chore(main): release 1.53.0 #1775

chore(main): release 1.53.0

chore(main): release 1.53.0 #1775

Workflow file for this run

# ==============================================================
# 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('평가 회귀 코멘트 신규 생성 완료');
}