merge: 안전한 릴리스 빌드 최적화 통합

This commit is contained in:
2026-08-19 18:06:21 +00:00
14 changed files with 261 additions and 24 deletions
+1
View File
@@ -173,3 +173,4 @@ playwright-report/
test-results/
uploads/
.release-dist/
app/game-frontend/.release-build/
+2
View File
@@ -6,6 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build:release": "vite build --outDir .release-build",
"typecheck:release": "vue-tsc --noEmit",
"preview": "vite preview",
"test": "pnpm test:unit",
"test:unit": "node --test test/**/*.test.ts",
@@ -49,13 +49,21 @@ export const buildTurboReleaseCommand = (
cacheAnchorRoot: string,
packageNames: string[],
env?: Record<string, string>
): BuildCommand => buildTurboReleaseTaskCommand(workspaceRoot, cacheAnchorRoot, 'build', packageNames, env);
export const buildTurboReleaseTaskCommand = (
workspaceRoot: string,
cacheAnchorRoot: string,
taskName: string,
packageNames: string[],
env?: Record<string, string>
): BuildCommand => ({
command: 'pnpm',
args: [
'exec',
'turbo',
'run',
'build',
taskName,
...packageNames.map((packageName) => `--filter=${packageName}`),
`--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`,
`--concurrency=${resolveReleaseTurboConcurrency(env)}`,
@@ -25,6 +25,7 @@ import { isRecord } from '@sammo-ts/common';
import {
buildTurboReleaseCommand,
buildTurboReleaseTaskCommand,
type BuildCommand,
type BuildProgressEvent,
type BuildProgressObserver,
@@ -530,7 +531,8 @@ const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string):
export const buildProfileFrontendCommands = (
workspaceRoot: string,
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
env?: Record<string, string>
env?: Record<string, string>,
cacheAnchorRoot: string = workspaceRoot
): BuildCommand[] => {
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
const buildEnv = {
@@ -540,17 +542,17 @@ export const buildProfileFrontendCommands = (
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
};
const outDir = buildProfileFrontendOutDir(workspaceRoot, profile.profileName);
return [
buildTurboReleaseTaskCommand(
workspaceRoot,
cacheAnchorRoot,
'build:release',
['@sammo-ts/game-frontend'],
buildEnv
),
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vue-tsc', '--noEmit'],
cwd: workspaceRoot,
env: buildEnv,
},
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vite', 'build', '--outDir', outDir],
command: 'node',
args: ['tools/build-scripts/materialize-profile-frontend.mjs', profile.profileName],
cwd: workspaceRoot,
env: buildEnv,
},
@@ -561,7 +563,8 @@ export const buildWorkspaceCommands = (
workspaceRoot: string,
needsInstall: boolean,
env?: Record<string, string>,
cacheAnchorRoot: string = workspaceRoot
cacheAnchorRoot: string = workspaceRoot,
packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api']
): BuildCommand[] => {
const commands: BuildCommand[] = [];
if (needsInstall) {
@@ -572,9 +575,7 @@ export const buildWorkspaceCommands = (
env,
});
}
commands.push(
buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env)
);
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, env));
return commands;
};
@@ -1430,9 +1431,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
workspace.root,
workspace.needsInstall,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot,
['@sammo-ts/game-api']
),
...buildProfileFrontendCommands(
workspace.root,
profile,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
];
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build'));
@@ -2004,7 +2011,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
...(profile
? buildProfileFrontendCommands(
workspace.root,
profile,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
: []),
];
if (operationId) {
await this.appendOperationLog(
+23
View File
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import {
buildTurboReleaseCommand,
buildTurboReleaseTaskCommand,
MAX_BUILD_OUTPUT_CHARS,
PnpmBuildRunner,
resolveReleaseTurboCacheDir,
@@ -58,6 +59,28 @@ describe('Turbo release build plan', () => {
env: { NODE_ENV: 'production' },
});
});
it('uses the same bounded cache policy for a release-specific task', () => {
expect(
buildTurboReleaseTaskCommand(
'/srv/core/profile-worktrees/commit',
'/srv/core/repository',
'build:release',
['@sammo-ts/game-frontend'],
{ VITE_APP_BASE_PATH: '/che' }
).args
).toEqual([
'exec',
'turbo',
'run',
'build:release',
'--filter=@sammo-ts/game-frontend',
'--cache-dir=/srv/core/repository/.turbo/release-cache',
'--concurrency=1',
'--ui=stream',
'--output-logs=new-only',
]);
});
});
describe('PnpmBuildRunner', () => {
@@ -315,6 +315,20 @@ describe('buildWorkspaceCommands', () => {
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
});
it('can limit a DB-preserving deploy to the game runtime server target', () => {
const commands = buildWorkspaceCommands(
'/srv/sammo/worktrees/0123456789abcdef',
false,
undefined,
'/srv/sammo/controller',
['@sammo-ts/game-api']
);
expect(commands[0]?.args).toContain('--filter=@sammo-ts/game-api');
expect(commands[0]?.args).not.toContain('--filter=@sammo-ts/gateway-api');
expect(commands[0]?.args).toContain('--concurrency=1');
});
it('deploys the game schema migration after building the selected workspace', () => {
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
const databaseUrl = 'postgresql://integration.invalid/sammo?schema=che';
@@ -347,6 +361,18 @@ describe('buildProfileFrontendCommands', () => {
(command) => command.env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS === '--max-old-space-size=2048'
)
).toBe(true);
expect(commands[0]?.args).toEqual([
'exec',
'turbo',
'run',
'build:release',
'--filter=@sammo-ts/game-frontend',
'--cache-dir=/srv/sammo/worktrees/0123456789abcdef/.turbo/release-cache',
'--concurrency=1',
'--ui=stream',
'--output-logs=new-only',
]);
expect(commands[1]?.args).toEqual(['tools/build-scripts/materialize-profile-frontend.mjs', 'che:2']);
});
it('keeps the shared Node heap when no frontend build override is configured', () => {
@@ -185,6 +185,15 @@ describe('profile DEPLOY operation', () => {
expect(commandGroups).toHaveLength(2);
expect(commandGroups[0]?.[0]?.args).toEqual(['install', '--frozen-lockfile']);
expect(commandGroups[0]?.[1]?.args).toContain('--filter=@sammo-ts/game-api');
expect(commandGroups[0]?.[1]?.args).not.toContain('--filter=@sammo-ts/gateway-api');
expect(commandGroups[0]?.[2]?.args).toContain('build:release');
expect(commandGroups[0]?.[2]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
expect(commandGroups[0]?.[2]?.args).toContain('--concurrency=1');
expect(commandGroups[0]?.[3]?.args).toEqual([
'tools/build-scripts/materialize-profile-frontend.mjs',
'che:1010',
]);
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
]);
@@ -0,0 +1,45 @@
import { execFile } from 'node:child_process';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
const execFileAsync = promisify(execFile);
const materializer = path.resolve(import.meta.dirname, '../../../tools/build-scripts/materialize-profile-frontend.mjs');
const temporaryRoots: string[] = [];
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('profile frontend materializer', () => {
it('replaces an existing profile artifact from the cached release build', async () => {
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'profile-frontend-materializer-'));
temporaryRoots.push(workspaceRoot);
const source = path.join(workspaceRoot, 'app', 'game-frontend', '.release-build');
const target = path.join(workspaceRoot, '.release-dist', 'che_2', 'game-frontend');
await mkdir(source, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(path.join(source, 'index.html'), 'new release');
await writeFile(path.join(target, 'index.html'), 'old release');
await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot });
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('new release');
});
it('keeps an existing artifact untouched when the cached build is missing', async () => {
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'profile-frontend-materializer-'));
temporaryRoots.push(workspaceRoot);
const target = path.join(workspaceRoot, '.release-dist', 'che_2', 'game-frontend');
await mkdir(target, { recursive: true });
await writeFile(path.join(target, 'index.html'), 'old release');
await expect(
execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot })
).rejects.toThrow();
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('old release');
});
});
+2
View File
@@ -10,6 +10,8 @@
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
"build": "vue-tsc && vite build",
"build:release": "vite build",
"typecheck:release": "vue-tsc --noEmit",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -5,6 +5,7 @@ import { stripVTControlCharacters } from 'node:util';
import {
assertReleaseComponents,
buildTurboReleaseCommand,
buildTurboReleaseTaskCommand,
type BuildCommand,
type BuildProgressEvent,
type BuildRunner,
@@ -43,10 +44,12 @@ const buildGatewayReleaseCommands = (
};
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
buildTurboReleaseCommand(
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env),
buildTurboReleaseTaskCommand(
workspaceRoot,
config.workspaceRoot,
['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'],
'build:release',
['@sammo-ts/gateway-frontend'],
env
),
];
@@ -220,6 +220,11 @@ describe('GatewayReleaseController', () => {
expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile');
expect(commandGroups[0]?.[1]).toContain('turbo run build');
expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
expect(commandGroups[0]?.[1]).toContain('--filter=@sammo-ts/gateway-api');
expect(commandGroups[0]?.[1]).not.toContain('--filter=@sammo-ts/gateway-frontend');
expect(commandGroups[0]?.[2]).toContain('turbo run build:release');
expect(commandGroups[0]?.[2]).toContain('--filter=@sammo-ts/gateway-frontend');
expect(commandGroups[0]?.[2]).toContain('--concurrency=1');
expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']);
expect([...running.keys()].sort()).toEqual([...gatewayNames].sort());
expect(harness.published).toEqual([
+20 -5
View File
@@ -51,15 +51,19 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
`.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
- `NODE_ENV`와 Vite가 추론한 `VITE_*`는 build hash에 포함됩니다. 따라서 base path나
API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다.
- Server build 뒤 frontend release는 `typecheck:release`와 Vite bundle을 별도 Turbo
task로 실행합니다. 타입검사 hash에는 frontend 자체와 직접 해석하는 common/infra/
logic/game-engine/game-api/gateway-api source, Prisma schema와 기본 navigation resource가
들어가며, bundle hash에는 `NODE_ENV`와 모든 `VITE_*`가 포함됩니다. 따라서 내부 type
source, base path나 API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지
않습니다. 일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
`NODE_OPTIONS``RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
build child에 전달됩니다.
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
turn-daemon PM2 process의 `NODE_OPTIONS`로만 덮어씁니다. API·frontend·worker는
공용 `NODE_OPTIONS`를 계속 사용합니다. Profile의 `vue-tsc`와 Vite build만 더 큰
heap이 필요하면 `PROFILE_FRONTEND_BUILD_NODE_OPTIONS`를 지정합니다. 이 값은
profile frontend build child`NODE_OPTIONS`만 덮어쓰며 server package Turbo
profile frontend release task`NODE_OPTIONS`만 덮어쓰며 server package Turbo
build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는
runtime container hard limit과 전체 process RSS를 먼저 확인합니다.
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
@@ -80,7 +84,8 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
1. 대상 commit의 game frontend, API, engineworker artifact를 빌드합니다.
1. 대상 commit의 game API target과 그 transitive engine/worker artifact를 빌드한 뒤,
profile frontend typecheck와 bundle을 공유 Turbo cache에서 복원하거나 생성합니다.
2. 기존 profile PM2 process를 정지합니다.
3. profile game schema에 `prisma migrate deploy`를 실행합니다.
4. Scenario seed를 실행하지 않고 frontend, API, daemon과 worker를 시작합니다.
@@ -90,6 +95,15 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
별도로 검토해 주세요.
Profile frontend bundle은 package의 `.release-build`에 고정 생성되어 profile base
path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
server target을 `game-api`로 제한합니다.
Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습니다. 이때 이미
열린 Gateway 로비의 profile 상세 조회가 실패하면 로비는 10초 request timeout과
1·2·3·5·8·15초(이후 15초 상한) 재시도로 자동 복구를 시도합니다. 상세가 아직
@@ -208,7 +222,8 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다.
2. Release manifest의 protocol, component와 migration head를 검증합니다.
3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다.
3. Gateway API server build를 공유 Turbo cache에서 준비하고, frontend는 별도 cached
typecheck와 Vite bundle task로 한 번씩 실행한 뒤 gateway migration을 적용합니다.
4. `sammo:gateway-api`, `sammo:gateway-frontend`,
`sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다.
5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를
@@ -0,0 +1,62 @@
import { randomUUID } from 'node:crypto';
import { cp, mkdir, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
const sanitizeArtifactName = (value) => value.replace(/[^0-9A-Za-z._-]+/g, '_');
const pathExists = async (target) => {
try {
await stat(target);
return true;
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw error;
}
};
export const materializeProfileFrontend = async (workspaceRoot, profileName) => {
const normalizedProfileName = profileName.trim();
if (!normalizedProfileName) throw new Error('profileName is required.');
const source = path.join(workspaceRoot, 'app', 'game-frontend', '.release-build');
const target = path.join(
workspaceRoot,
'.release-dist',
sanitizeArtifactName(normalizedProfileName),
'game-frontend'
);
await stat(path.join(source, 'index.html'));
const parent = path.dirname(target);
const nonce = `${process.pid}-${randomUUID()}`;
const staging = path.join(parent, `.game-frontend-staging-${nonce}`);
const previous = path.join(parent, `.game-frontend-previous-${nonce}`);
await mkdir(parent, { recursive: true });
await cp(source, staging, { recursive: true, errorOnExist: true, force: false });
let movedPrevious = false;
try {
if (await pathExists(target)) {
await rename(target, previous);
movedPrevious = true;
}
await rename(staging, target);
if (movedPrevious) await rm(previous, { recursive: true, force: true });
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (movedPrevious && !(await pathExists(target))) await rename(previous, target);
throw error;
}
return target;
};
const isDirectExecution = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isDirectExecution) {
const profileName = process.argv[2];
if (!profileName) throw new Error('Usage: materialize-profile-frontend.mjs <profileName>');
const target = await materializeProfileFrontend(process.cwd(), profileName);
process.stdout.write(`Profile frontend materialized at ${target}\n`);
}
+22
View File
@@ -9,6 +9,28 @@
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
},
"build:release": {
"dependsOn": ["typecheck:release"],
"env": ["NODE_ENV", "VITE_*"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".release-build/**"]
},
"typecheck:release": {
"dependsOn": ["@sammo-ts/infra#prisma:generate"],
"inputs": [
"$TURBO_DEFAULT$",
"$TURBO_ROOT$/tsconfig*.json",
"$TURBO_ROOT$/resources/navigation.json",
"$TURBO_ROOT$/packages/common/src/**",
"$TURBO_ROOT$/packages/infra/src/**",
"$TURBO_ROOT$/packages/infra/prisma/*.prisma",
"$TURBO_ROOT$/packages/logic/src/**",
"$TURBO_ROOT$/app/game-engine/src/**",
"$TURBO_ROOT$/app/game-api/src/**",
"$TURBO_ROOT$/app/gateway-api/src/**"
],
"cache": true
},
"typecheck": {
"dependsOn": ["^build", "prisma:generate"],
"cache": true