Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c65dbda
ci(root): PR 번들 사이즈 자동 분석 워크플로우 추가 (#15)
kimminna Jun 24, 2026
5d953e3
fix(ui): 디자인 시스템 패키지명 변경 사항 적용 (#15)
kimminna Jun 24, 2026
742793f
ci(root): Turbo TUI 비활성화 및 Node 24로 업그레이드 (#15)
kimminna Jun 24, 2026
80ae4f5
ci(root): turbo 플래그 --log-output을 --output-logs로 수정 (#15)
kimminna Jun 24, 2026
4f5ab0b
ci(root): 번들 크기 분석을 .next 디렉토리 파싱으로 전환 (#15)
kimminna Jun 24, 2026
84d42a0
ci(root): Turbopack 빌드용 routes-manifest 기반 분석으로 전환 (#15)
kimminna Jun 24, 2026
c1a10d8
ci(root): gzip 크기 측정 및 내부 라우트 필터링 (#15)
kimminna Jun 24, 2026
6d1ed19
ci(root): 번들 사이즈 코멘트를 삭제 후 재생성에서 업데이트 방식으로 변경 (#15)
kimminna Jun 24, 2026
49c9f13
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
kimminna Jun 24, 2026
9811b76
ci(root): Debug 스텝을 빌드 실패 시에만 실행되도록 수정 (#15)
kimminna Jun 25, 2026
eae6013
fix(root): PR 템플릿 파일명 오타 수정 (#15)
kimminna Jun 25, 2026
ff5fe7c
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
kimminna Jun 25, 2026
833dcdd
fix(root): label-by-files와 label-pr 간 라벨 충돌 수정 (#15)
kimminna Jun 25, 2026
c8708c8
fix(root): listFiles 페이지네이션 누락으로 인한 라벨 보호 오류 수정 (#15)
kimminna Jun 25, 2026
6bb845b
fix(root): safeGzipSize 오류 무시 제거로 번들 분석 실패 명시화 (#15)
kimminna Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
205 changes: 205 additions & 0 deletions .github/scripts/bundle-size-report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
'use strict';

const fs = require('fs');
const path = require('path');
const zlib = require('zlib');

const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next');

const formatBytes = (bytes) => {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} kB`;
return `${bytes} B`;
};

const toKb = (bytes) => bytes / 1024;

const safeGzipSize = (filePath) => {
try {
const content = fs.readFileSync(filePath);
return zlib.gzipSync(content).length;
} catch (err) {
throw new Error(`번들 파일 처리 실패: ${filePath} — ${err.message}`);
}
};

const readJson = (filePath) => {
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
};

const sumDirGzipSize = (dirPath) => {
if (!fs.existsSync(dirPath)) return 0;
return fs.readdirSync(dirPath).reduce((sum, file) => {
const full = path.join(dirPath, file);
const stat = fs.statSync(full);
return sum + (stat.isFile() ? safeGzipSize(full) : 0);
}, 0);
};

const analyzeBuild = (buildDir) => {
if (!fs.existsSync(buildDir)) return { routes: [], sharedSize: null };

const buildManifest = readJson(path.join(buildDir, 'build-manifest.json'));
const routesManifest = readJson(path.join(buildDir, 'routes-manifest.json'));
// webpack 빌드에서만 생성됨
const appBuildManifest = readJson(path.join(buildDir, 'app-build-manifest.json'));

if (!buildManifest) return { routes: [], sharedSize: null };

// 내부 Next.js 라우트 필터 (사용자 정의 라우트가 아님)
const INTERNAL_ROUTES = new Set(['/_not-found', '/_global-error']);

// 공유 번들 gzip 크기 (rootMainFiles)
const rootMainFiles = buildManifest.rootMainFiles ?? [];
const sharedBytes = rootMainFiles.reduce(
(sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)),
0
);

let routes = [];

if (appBuildManifest) {
// webpack 모드: app-build-manifest.json으로 라우트별 청크 매핑
const pages = appBuildManifest.pages ?? {};
routes = Object.entries(pages)
.filter(([route]) => !INTERNAL_ROUTES.has(route))
.map(([route, chunks]) => {
const pageBytes = chunks.reduce(
(sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)),
0
);
const firstLoadBytes = pageBytes + sharedBytes;
return {
path: route,
size: formatBytes(pageBytes),
firstLoad: formatBytes(firstLoadBytes),
firstLoadKb: toKb(firstLoadBytes),
};
});
} else {
// Turbopack 모드: routes-manifest.json으로 라우트 목록 구성
const staticRoutes = [
...(routesManifest?.staticRoutes ?? []),
...(routesManifest?.dynamicRoutes ?? []),
];

const appChunksDir = path.join(buildDir, 'static', 'chunks', 'app');

routes = staticRoutes
.filter(({ page }) => !INTERNAL_ROUTES.has(page))
.map(({ page }) => {
const segment = page === '/' ? '' : page;
const pageChunkDir = path.join(appChunksDir, segment);
const pageBytes = sumDirGzipSize(pageChunkDir);

const firstLoadBytes = pageBytes + sharedBytes;
return {
path: page,
size: formatBytes(pageBytes),
firstLoad: formatBytes(firstLoadBytes),
firstLoadKb: toKb(firstLoadBytes),
};
});

// 라우트가 없으면 공유 번들만이라도 / 라우트로 표시
if (routes.length === 0 && sharedBytes > 0) {
routes = [{
path: '/',
size: '0 B',
firstLoad: formatBytes(sharedBytes),
firstLoadKb: toKb(sharedBytes),
}];
}
}

return { routes, sharedSize: formatBytes(sharedBytes) };
};

const WARN_THRESHOLD_KB = 200;
const ERROR_THRESHOLD_KB = 350;

const sizeIcon = (firstLoadKb) => {
if (firstLoadKb >= ERROR_THRESHOLD_KB) return '🔴';
if (firstLoadKb >= WARN_THRESHOLD_KB) return '🟡';
return '🟢';
};

const formatRouteTable = (appLabel, { routes, sharedSize }) => {
if (!routes.length) {
return `### ${appLabel}\n\n> ⚠️ 번들 크기 데이터를 가져오지 못했습니다 (.next 디렉토리를 확인하세요).\n`;
}

const rows = routes
.map(
(r) =>
`| \`${r.path}\` | ${r.size} | ${r.firstLoad} | ${sizeIcon(r.firstLoadKb)} |`
)
.join('\n');

const sharedLine = sharedSize ? `\n> 공유 번들: **${sharedSize}**\n` : '';

return `### ${appLabel}

| 라우트 | 크기 | First Load JS | 상태 |
|--------|------|---------------|------|
${rows}
${sharedLine}`;
};

module.exports = async ({ github, context, core }) => {
const prNumber = context.payload.pull_request?.number;
if (!prNumber) {
core.warning('PR 컨텍스트를 찾을 수 없습니다.');
return;
}

const { owner, repo } = context.repo;

core.info(`번들 분석 경로: ${BUILD_DIR}`);
const timoWebBuild = analyzeBuild(BUILD_DIR);

if (!timoWebBuild.routes.length) {
core.warning(`번들 크기 데이터를 파싱하지 못했습니다. ${BUILD_DIR} 를 확인하세요.`);
return;
Comment thread
kimminna marked this conversation as resolved.
}

const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준 (gzip 크기)`;

const body = `## 📦 번들 사이즈 리포트

${formatRouteTable('🕐 Timo Web', timoWebBuild)}

---

${legend}

*빌드 커밋: \`${context.sha.slice(0, 7)}\`*`;

const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
});

const existing = comments.find(
(c) => c.user?.type === 'Bot' && c.body?.includes('번들 사이즈 리포트')
);

if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
}

core.info('번들 사이즈 리포트 PR 코멘트 게시 완료');
};
20 changes: 20 additions & 0 deletions .github/workflows/auto-label.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ jobs:
});
const currentLabelNames = new Set(currentLabels.map(l => l.name));

// labeler.yml이 파일 경로 기준으로 관리하는 라벨은 파일 변경 여부로 보호
// label-by-files job이 추가했을 수 있으므로 prefix 기반 제거 대상에서 제외
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
const fileLabelProtection = new Map([
['⌚ Timo-Design-system', 'packages/timo-design-system/'],
['⏰ Timo-web', 'apps/timo-web/'],
]);
const protectedByFiles = new Set(
[...fileLabelProtection.entries()]
.filter(([, pathPrefix]) => changedFiles.some(f => f.filename.startsWith(pathPrefix)))
.map(([label]) => label)
);

// --- prefix 라벨 ---
// edited 이벤트는 본문 수정만으로도 트리거되므로 제목 변경 여부를 확인
if (action !== 'edited' || context.payload.changes?.title) {
Expand All @@ -73,6 +91,8 @@ jobs:

for (const label of currentLabels) {
if (allPrefixLabels.includes(label.name) && label.name !== desiredLabel) {
// 파일 변경으로 인해 label-by-files가 추가한 라벨은 제거하지 않음
if (protectedByFiles.has(label.name)) continue;
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: 📦 번들 사이즈 리포트

on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main, develop]

permissions:
contents: read
pull-requests: write
Comment thread
kimminna marked this conversation as resolved.

concurrency:
group: bundle-size-${{ github.ref }}
cancel-in-progress: true

jobs:
bundle-size:
name: Bundle Size Report
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment thread
kimminna marked this conversation as resolved.

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 24
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build timo-web
id: build
env:
TURBO_UI: false
NO_COLOR: '1'
run: pnpm turbo run build --filter=timo-web --output-logs=full 2>&1 | tee /tmp/timo-web-build.txt
continue-on-error: true
Comment thread
kimminna marked this conversation as resolved.

- name: Debug - 빌드 결과 확인
if: steps.build.outcome == 'failure'
run: |
echo "=== .next 디렉토리 ==="
ls apps/timo-web/.next/ 2>/dev/null || echo "(.next 없음)"
echo "=== routes-manifest staticRoutes ==="
node -e "const m=require('./apps/timo-web/.next/routes-manifest.json');console.log(JSON.stringify(m.staticRoutes,null,2))" 2>/dev/null || echo "(없음)"
echo "=== build-manifest rootMainFiles ==="
node -e "const m=require('./apps/timo-web/.next/build-manifest.json');console.log(JSON.stringify(m.rootMainFiles,null,2))" 2>/dev/null || echo "(없음)"
echo "=== static/chunks/app 디렉토리 ==="
ls apps/timo-web/.next/static/chunks/app/ 2>/dev/null || echo "(없음)"

- name: 번들 사이즈 PR 코멘트 게시
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fn = require('./.github/scripts/bundle-size-report.js')
await fn({ github, context, core })
Loading