Merge remote-tracking branch 'origin/main' into feature/ui-action-feedback-20260811

This commit is contained in:
2026-08-11 12:03:07 +00:00
10 changed files with 159 additions and 59 deletions
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
export interface BuildCommand {
command: string;
@@ -25,6 +26,35 @@ export interface BuildRunner {
}
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
export const RELEASE_TURBO_CONCURRENCY = 2;
export const resolveReleaseTurboCacheDir = (cacheAnchorRoot: string, env?: Record<string, string>): string => {
const configured = env?.TURBO_CACHE_DIR?.trim();
if (!configured) return path.join(path.resolve(cacheAnchorRoot), '.turbo', 'release-cache');
return path.isAbsolute(configured) ? configured : path.resolve(cacheAnchorRoot, configured);
};
export const buildTurboReleaseCommand = (
workspaceRoot: string,
cacheAnchorRoot: string,
packageNames: string[],
env?: Record<string, string>
): BuildCommand => ({
command: 'pnpm',
args: [
'exec',
'turbo',
'run',
'build',
...packageNames.map((packageName) => `--filter=${packageName}`),
`--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`,
`--concurrency=${RELEASE_TURBO_CONCURRENCY}`,
'--ui=stream',
'--output-logs=new-only',
],
cwd: workspaceRoot,
env,
});
const appendOutputTail = (current: string, chunk: unknown): string =>
`${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS);
@@ -12,7 +12,7 @@ import {
} from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common';
import type { BuildCommand, BuildRunner } from './buildRunner.js';
import { buildTurboReleaseCommand, type BuildCommand, type BuildRunner } from './buildRunner.js';
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
import type {
GatewayClaimedProfileUpdate,
@@ -494,7 +494,8 @@ export const buildProfileFrontendCommands = (
export const buildWorkspaceCommands = (
workspaceRoot: string,
needsInstall: boolean,
env?: Record<string, string>
env?: Record<string, string>,
cacheAnchorRoot: string = workspaceRoot
): BuildCommand[] => {
const commands: BuildCommand[] = [];
if (needsInstall) {
@@ -505,23 +506,9 @@ export const buildWorkspaceCommands = (
env,
});
}
const buildSteps: Array<[filter: string, script: string]> = [
['@sammo-ts/common', 'build'],
['@sammo-ts/infra', 'prisma:generate'],
['@sammo-ts/infra', 'build'],
['@sammo-ts/logic', 'build'],
['@sammo-ts/game-api', 'build'],
['@sammo-ts/game-engine', 'build'],
['@sammo-ts/gateway-api', 'build'],
];
for (const [filter, script] of buildSteps) {
commands.push({
command: 'pnpm',
args: ['--filter', filter, script],
cwd: workspaceRoot,
env,
});
}
commands.push(
buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env)
);
return commands;
};
@@ -1061,7 +1048,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const manifest = await readReleaseManifest(workspace.root);
assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']);
const commands = [
...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv),
...buildWorkspaceCommands(
workspace.root,
workspace.needsInstall,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
];
const result = await this.buildRunner.run(commands);
@@ -1552,7 +1544,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}> {
const workspace = await this.workspaceManager.prepare(commitSha);
const commands = [
...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv),
...buildWorkspaceCommands(
workspace.root,
workspace.needsInstall,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
];
return { result: await this.buildRunner.run(commands), workspace };
+48 -1
View File
@@ -2,7 +2,54 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { MAX_BUILD_OUTPUT_CHARS, PnpmBuildRunner } from '../src/orchestrator/buildRunner.js';
import {
buildTurboReleaseCommand,
MAX_BUILD_OUTPUT_CHARS,
PnpmBuildRunner,
resolveReleaseTurboCacheDir,
} from '../src/orchestrator/buildRunner.js';
describe('Turbo release build plan', () => {
it('anchors the default cache outside commit worktrees and allows an operator override', () => {
expect(resolveReleaseTurboCacheDir('/srv/core/repository')).toBe('/srv/core/repository/.turbo/release-cache');
expect(
resolveReleaseTurboCacheDir('/srv/core/repository', {
TURBO_CACHE_DIR: '/srv/core/cache/turbo',
})
).toBe('/srv/core/cache/turbo');
expect(
resolveReleaseTurboCacheDir('/srv/core/repository', {
TURBO_CACHE_DIR: '.cache/turbo',
})
).toBe('/srv/core/repository/.cache/turbo');
});
it('uses a bounded streaming Turbo build for the selected packages', () => {
expect(
buildTurboReleaseCommand(
'/srv/core/profile-worktrees/commit',
'/srv/core/repository',
['@sammo-ts/game-api'],
{ NODE_ENV: 'production' }
)
).toEqual({
command: 'pnpm',
args: [
'exec',
'turbo',
'run',
'build',
'--filter=@sammo-ts/game-api',
'--cache-dir=/srv/core/repository/.turbo/release-cache',
'--concurrency=2',
'--ui=stream',
'--output-logs=new-only',
],
cwd: '/srv/core/profile-worktrees/commit',
env: { NODE_ENV: 'production' },
});
});
});
describe('PnpmBuildRunner', () => {
it('returns a failed result when a command cannot be spawned', async () => {
+13 -8
View File
@@ -225,17 +225,22 @@ describe('sanitizeManagedProcessEnv', () => {
describe('buildWorkspaceCommands', () => {
it('installs and builds runtime dependencies before the profile processes', () => {
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
const commands = buildWorkspaceCommands(workspaceRoot, true);
const commands = buildWorkspaceCommands(workspaceRoot, true, undefined, '/srv/sammo/controller');
expect(commands.map(({ args }) => args)).toEqual([
['install', '--frozen-lockfile'],
['--filter', '@sammo-ts/common', 'build'],
['--filter', '@sammo-ts/infra', 'prisma:generate'],
['--filter', '@sammo-ts/infra', 'build'],
['--filter', '@sammo-ts/logic', 'build'],
['--filter', '@sammo-ts/game-api', 'build'],
['--filter', '@sammo-ts/game-engine', 'build'],
['--filter', '@sammo-ts/gateway-api', 'build'],
[
'exec',
'turbo',
'run',
'build',
'--filter=@sammo-ts/game-api',
'--filter=@sammo-ts/gateway-api',
'--cache-dir=/srv/sammo/controller/.turbo/release-cache',
'--concurrency=2',
'--ui=stream',
'--output-logs=new-only',
],
]);
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
});
+4 -7
View File
@@ -29,6 +29,9 @@ Gateway process 환경에 전달하지 않습니다. 이 값이 frontend 정의
frontend build 계약입니다.
- `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue
poll과 준비 제한 시간입니다.
- `TURBO_CACHE_DIR`: 선택 사항인 공유 local cache 경로입니다. 없으면 원래
`RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는
원래 workspace 기준으로 해석합니다.
비밀값은 Git에서 제외된 환경 파일 또는 process 환경으로 전달해 주세요.
`VITE_*`에는 공개 URL만 넣어 주세요.
@@ -46,13 +49,7 @@ DEPLOY의 rollback이 frontend build가 없는 controller worktree를 이전 Gat
```sh
pnpm install --frozen-lockfile
pnpm --filter @sammo-ts/infra prisma:generate
pnpm --filter @sammo-ts/common build
pnpm --filter @sammo-ts/infra build
pnpm --filter @sammo-ts/logic build
pnpm --filter @sammo-ts/game-engine build
pnpm --filter @sammo-ts/gateway-api build
pnpm --filter @sammo-ts/release-controller build
pnpm exec turbo run build --filter=@sammo-ts/release-controller --concurrency=2 --ui=stream
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
pnpm --filter @sammo-ts/release-controller start
```
@@ -4,6 +4,7 @@ import { stripVTControlCharacters } from 'node:util';
import {
assertReleaseComponents,
buildTurboReleaseCommand,
type BuildCommand,
type BuildProgressEvent,
type BuildRunner,
@@ -38,13 +39,12 @@ export const buildGatewayReleaseCommands = (
};
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
{ command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-frontend', 'build'], cwd: workspaceRoot, env },
buildTurboReleaseCommand(
workspaceRoot,
config.workspaceRoot,
['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'],
env
),
];
};
@@ -244,7 +244,12 @@ export class GatewayReleaseController {
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id);
await this.waitForReadiness(operation.id);
} catch (error) {
await this.appendLog(operation.id, 'rollback', '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', 'ERROR');
await this.appendLog(
operation.id,
'rollback',
'새 Gateway 시작에 실패하여 이전 process를 복구합니다.',
'ERROR'
);
await this.stopManagedProcesses(operation.id);
if (previousDefinitions.length) {
await this.startDefinitions(previousDefinitions, operation.id);
+2 -7
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import {
assertReleaseComponents,
buildTurboReleaseCommand,
type BuildCommand,
type BuildRunner,
type GitWorkspaceManager,
@@ -24,13 +25,7 @@ export const buildReleaseControllerCommands = (
const env = sanitizeManagedProcessEnv(config.baseEnv);
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
{ command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env },
{ command: 'pnpm', args: ['--filter', '@sammo-ts/release-controller', 'build'], cwd: workspaceRoot, env },
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env),
];
};
@@ -209,6 +209,8 @@ describe('GatewayReleaseController', () => {
expect(commandGroups).toHaveLength(2);
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[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']);
expect([...running.keys()].sort()).toEqual([...gatewayNames].sort());
expect(harness.published).toEqual([
@@ -216,7 +218,16 @@ describe('GatewayReleaseController', () => {
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
expect(harness.logs.map((entry) => entry.phase)).toEqual(
expect.arrayContaining(['claim', 'resolve', 'workspace', 'build', 'migration', 'switch', 'readiness', 'publish'])
expect.arrayContaining([
'claim',
'resolve',
'workspace',
'build',
'migration',
'switch',
'readiness',
'publish',
])
);
});
@@ -275,8 +286,7 @@ describe('GatewayReleaseController', () => {
await onProgress?.({
type: 'OUTPUT',
stream: 'stdout',
message:
'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo',
message: 'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo',
});
return { ok: true, exitCode: 0, output: '' };
},
@@ -405,7 +415,7 @@ describe('upgradeReleaseController', () => {
).resolves.toEqual({ commitSha: SHA, workspace });
expect(commandGroups).toHaveLength(2);
expect(commandGroups[0]?.at(-1)).toBe('--filter @sammo-ts/release-controller build');
expect(commandGroups[0]?.at(-1)).toContain('turbo run build --filter=@sammo-ts/release-controller');
expect(starts.at(-1)).toMatchObject({
name: 'sammo:release-controller',
cwd: path.join(workspace, 'app', 'release-controller'),
+12 -1
View File
@@ -44,6 +44,15 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
- Root와 server package의 `tsdown`은 0.22.14 계열로 통일합니다. Docker runtime의
Node heap/Rayon 상한을 상속한 동일 toolchain으로 초기 Gateway와 profile
worktree를 빌드하여 구형 Rolldown의 과도한 native thread 생성을 피합니다.
- Profile, Gateway와 controller self-upgrade의 server package build는 Turbo DAG를
동시성 2로 실행합니다. 기본 local cache는 원래 Core checkout의
`.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로 잘못 복원하지 않습니다.
`NODE_OPTIONS``RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
build child에 전달됩니다.
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
유지됩니다.
@@ -113,7 +122,7 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다.
2. Release manifest의 protocol, component와 migration head를 검증합니다.
3. Gateway API와 frontend를 빌드하고 gateway migration을 적용합니다.
3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다.
4. `sammo:gateway-api`, `sammo:gateway-frontend`,
`sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다.
5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를
@@ -137,6 +146,8 @@ Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있
commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
같은 이력에서 다시 열 수 있으며 화면은 최근 1,000줄을 유지합니다.
Build 로그의 `cache hit`/`cache miss`와 마지막 `Cached: N cached, M total`은 실제
이번 릴리스의 cache 사용 여부를 나타냅니다.
로그 원본은 Gateway DB의 `GatewayReleaseLog`에 작업별로 저장되고 작업 삭제 시
함께 제거됩니다. Controller는 ANSI 제어 문자를 제거하고 secret·token·password
+5 -2
View File
@@ -1,17 +1,20 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"globalPassThroughEnv": ["NODE_OPTIONS", "RAYON_NUM_THREADS"],
"tasks": {
"build": {
"dependsOn": ["^build", "typecheck"],
"env": ["NODE_ENV"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^typecheck", "prisma:generate"],
"dependsOn": ["^build", "prisma:generate"],
"cache": true
},
"prisma:generate": {
"inputs": ["prisma/*.prisma"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["node_modules/.prisma/client/**", "prisma/generated/**"],
"cache": true
},