perf: 릴리스 프론트엔드 빌드를 안전하게 캐시
Gateway와 Profile 릴리스의 타입검사와 Vite 번들을 별도 Turbo 작업으로 캐시한다. DB 보존 DEPLOY는 game-api만 빌드하고 기본 동시성 1과 기존 산출물 계약은 유지한다.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user