merge: 일반 리셋 브랜치 자동 갱신 통합

This commit is contained in:
2026-08-19 13:45:48 +00:00
14 changed files with 259 additions and 35 deletions
+14 -6
View File
@@ -21,6 +21,7 @@ import {
import type { GatewayApiContext } from './context.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js';
import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from './profileOrder.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
@@ -1210,9 +1211,10 @@ export const adminRouter = router({
});
}
const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode;
let sourceRef =
input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim();
const configuredSource = input.sourceMode === 'CURRENT' ? readProfileReleaseSource(profile) : null;
const sourceMode: 'BRANCH' | 'COMMIT' =
input.sourceMode === 'CURRENT' ? (configuredSource?.mode ?? 'COMMIT') : input.sourceMode;
let sourceRef = configuredSource?.ref ?? input.sourceRef?.trim();
if (!sourceRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -1253,6 +1255,7 @@ export const adminRouter = router({
payload: {
install: input.install,
requestedSource: input.sourceMode,
releaseSource: { mode: sourceMode, ref: sourceRef },
} as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
@@ -1372,6 +1375,7 @@ export const adminRouter = router({
type: 'DEPLOY',
sourceMode: input.sourceMode,
sourceRef,
payload: { releaseSource: { mode: input.sourceMode, ref: sourceRef } },
reason: input.reason,
requestedBy: adminAuth.user.id,
});
@@ -1740,6 +1744,8 @@ export const adminRouter = router({
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const sourceMode = input?.sourceMode ?? 'CURRENT';
let resolvedSourceMode: 'BRANCH' | 'COMMIT' | undefined =
sourceMode === 'CURRENT' ? undefined : sourceMode;
let gitRef = input?.gitRef?.trim();
let currentScenarioId: number | null = null;
if (sourceMode === 'CURRENT') {
@@ -1754,8 +1760,10 @@ export const adminRouter = router({
const parsedScenarioId =
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
gitRef = profile.buildCommitSha?.trim();
if (!gitRef) {
const configuredSource = readProfileReleaseSource(profile);
gitRef = configuredSource?.ref;
resolvedSourceMode = configuredSource?.mode;
if (!configuredSource || !gitRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The profile has no active build commit.',
@@ -1771,7 +1779,7 @@ export const adminRouter = router({
? await listScenarioPreviews()
: await listScenarioPreviews({
gitRef:
sourceMode === 'BRANCH'
resolvedSourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(gitRef)
: await resolveGitCommitSha(gitRef),
});
@@ -38,6 +38,11 @@ import type {
GatewayProfileRepository,
GatewayProfileStatus,
} from './profileRepository.js';
import {
canReuseActiveProfileWorkspace,
writeProfileReleaseSource,
type ProfileReleaseSource,
} from './profileReleaseSource.js';
import type { GitWorkspaceManager } from './workspaceManager.js';
import type { AdminSeedUser } from './seedProfileDatabase.js';
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
@@ -197,6 +202,16 @@ class OperationLeaseLostError extends Error {}
const normalizeMeta = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const readOperationReleaseSource = (operation: GatewayOperationRecord): ProfileReleaseSource => {
const stored = normalizeMeta(normalizeMeta(operation.payload).releaseSource);
const mode = stored.mode;
const ref = typeof stored.ref === 'string' ? stored.ref.trim() : '';
if ((mode === 'BRANCH' || mode === 'COMMIT') && ref) {
return { mode, ref };
}
return { mode: operation.sourceMode!, ref: operation.sourceRef! };
};
export const buildTournamentRuntimeKeys = (profileName: string): string[] => [
`sammo:${profileName}:tournament:state`,
`sammo:${profileName}:tournament:participants`,
@@ -1167,7 +1182,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
if (operation.type === 'DEPLOY') {
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
const result = await this.handleProfileDeploy(
profile,
commitSha,
assertLease,
operation.id,
readOperationReleaseSource(operation)
);
if (!result.ok) {
throw new Error(result.detail);
}
@@ -1192,7 +1213,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
installOperationId,
install,
};
const result = await this.handleResetAction(profile, resetAction, commitSha, assertLease, operation.id);
const result = await this.handleResetAction(
profile,
resetAction,
commitSha,
assertLease,
operation.id,
readOperationReleaseSource(operation)
);
if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
await this.appendOperationLog(
@@ -1360,7 +1388,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
profile: GatewayProfileRecord,
commitSha: string,
assertLease: () => Promise<void>,
operationId: string
operationId: string,
releaseSource: ProfileReleaseSource
): Promise<{ ok: true } | { ok: false; detail: string }> {
if (this.buildInFlight) {
return { ok: false, detail: 'build already in progress' };
@@ -1498,6 +1527,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
buildCompletedAt: completedAt,
buildError: null,
lastError: null,
meta: writeProfileReleaseSource(profile.meta, releaseSource),
});
await this.appendOperationLog(
operationId,
@@ -1617,7 +1647,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
action: GatewayAdminActionRecord,
commitShaOverride?: string,
assertLease?: () => Promise<void>,
operationId?: string
operationId?: string,
releaseSource?: ProfileReleaseSource
): Promise<GatewayAdminActionResult> {
const appendLog = async (
phase: string,
@@ -1819,6 +1850,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null,
...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}),
},
async () => {
await this.repository.updateWorkspaceUsage(profile.profileName, workspace.root, completedAt);
@@ -1951,6 +1983,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (operationId) {
await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`);
}
const activeWorkspaceReusable = canReuseActiveProfileWorkspace(profile, commitSha, workspace);
if (activeWorkspaceReusable) {
if (operationId) {
await this.appendOperationLog(
operationId,
'build',
'이미 최신 커밋의 빌드 산출물이 준비되어 있어 빌드를 생략합니다.'
);
}
return {
result: { ok: true, exitCode: 0, output: '' },
workspace,
};
}
const commands = [
...buildWorkspaceCommands(
workspace.root,
@@ -0,0 +1,46 @@
import path from 'node:path';
import type { GatewayPrisma } from '@sammo-ts/infra';
import type { GatewayProfileRecord, GatewaySourceMode } from './profileRepository.js';
import type { WorkspaceInfo } from './workspaceManager.js';
export interface ProfileReleaseSource {
mode: GatewaySourceMode;
ref: string;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => {
const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null;
const mode = stored?.mode;
const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : '';
if ((mode === 'BRANCH' || mode === 'COMMIT') && ref) {
return { mode, ref };
}
const activeCommit = profile.buildCommitSha?.trim();
return activeCommit ? { mode: 'COMMIT', ref: activeCommit } : null;
};
export const writeProfileReleaseSource = (
meta: GatewayPrisma.JsonObject,
source: ProfileReleaseSource
): GatewayPrisma.JsonObject => ({
...meta,
releaseSource: {
mode: source.mode,
ref: source.ref,
},
});
export const canReuseActiveProfileWorkspace = (
profile: GatewayProfileRecord | undefined,
commitSha: string,
workspace: WorkspaceInfo
): boolean =>
profile?.buildCommitSha === commitSha &&
typeof profile.buildWorkspace === 'string' &&
path.resolve(profile.buildWorkspace) === path.resolve(workspace.root) &&
!workspace.needsInstall;
@@ -122,6 +122,7 @@ export interface GatewayClaimedProfileUpdate {
buildCompletedAt?: string | null;
buildError?: string | null;
lastError?: string | null;
meta?: GatewayPrisma.JsonObject;
}
export interface GatewayProfileRepository {
@@ -748,6 +749,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
buildCompletedAt: toDate(patch.buildCompletedAt),
buildError: patch.buildError,
lastError: patch.lastError,
meta: patch.meta,
},
});
});
+12 -5
View File
@@ -613,7 +613,7 @@ describe('admin operation API', () => {
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('queues a DB-preserving profile deployment without reset payload', async () => {
it('queues a DB-preserving profile deployment with a durable release policy and no reset payload', async () => {
const harness = await buildCaller(
async (input) => ({
id: '33333333-3333-4333-8333-333333333333',
@@ -642,11 +642,17 @@ describe('admin operation API', () => {
type: 'DEPLOY',
sourceMode: 'COMMIT',
reason: 'preserve live season',
payload: {
releaseSource: {
mode: 'COMMIT',
ref: expect.stringMatching(/^[0-9a-f]{40}$/u),
},
},
});
expect(harness.createdInputs[0]).not.toHaveProperty('payload');
expect(harness.createdInputs[0]?.payload).not.toHaveProperty('install');
});
it('lets a scenario-only operator reset from the active commit without selecting Git', async () => {
it('lets a scenario-only operator reset from the configured branch latest without selecting Git', async () => {
const harness = await buildCaller(
async (input) => ({
id: '55555555-5555-4555-8555-555555555555',
@@ -664,6 +670,7 @@ describe('admin operation API', () => {
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileScenario: '1010',
profileMeta: { releaseSource: { mode: 'BRANCH', ref: 'main' } },
}
);
@@ -687,8 +694,8 @@ describe('admin operation API', () => {
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
sourceMode: 'BRANCH',
sourceRef: 'main',
reason: 'new season only',
});
});
@@ -193,6 +193,7 @@ describe('profile DEPLOY operation', () => {
buildStatus: 'SUCCEEDED',
buildCommitSha: SHA,
buildWorkspace: workspace,
meta: { releaseSource: { mode: 'COMMIT', ref: SHA } },
});
expect(completions).toEqual(['SUCCEEDED']);
expect(logs).toEqual(
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
canReuseActiveProfileWorkspace,
readProfileReleaseSource,
writeProfileReleaseSource,
} from '../src/orchestrator/profileReleaseSource.js';
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
const profile = (overrides: Partial<GatewayProfileRecord> = {}): GatewayProfileRecord => ({
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '1010',
scenario: '1010',
apiPort: 15003,
status: 'RUNNING',
buildStatus: 'SUCCEEDED',
buildCommitSha: 'a'.repeat(40),
buildWorkspace: `/srv/sammo/worktrees/${'a'.repeat(40)}`,
meta: {},
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z',
...overrides,
});
describe('profile release source policy', () => {
it('follows the stored branch instead of pinning the active commit', () => {
const current = profile({ meta: { releaseSource: { mode: 'BRANCH', ref: 'main' } } });
expect(readProfileReleaseSource(current)).toEqual({ mode: 'BRANCH', ref: 'main' });
});
it('falls back to the active commit for profiles without a stored policy', () => {
expect(readProfileReleaseSource(profile())).toEqual({ mode: 'COMMIT', ref: 'a'.repeat(40) });
});
it('preserves unrelated metadata while changing the privileged release policy', () => {
expect(writeProfileReleaseSource({ nextSeasonIdx: 3 }, { mode: 'COMMIT', ref: 'b'.repeat(40) })).toEqual({
nextSeasonIdx: 3,
releaseSource: { mode: 'COMMIT', ref: 'b'.repeat(40) },
});
});
it('reuses installed artifacts only for the same active commit and workspace', () => {
const current = profile();
expect(
canReuseActiveProfileWorkspace(current, 'a'.repeat(40), {
root: current.buildWorkspace!,
created: false,
needsInstall: false,
})
).toBe(true);
expect(
canReuseActiveProfileWorkspace(current, 'b'.repeat(40), {
root: current.buildWorkspace!,
created: false,
needsInstall: false,
})
).toBe(false);
expect(
canReuseActiveProfileWorkspace(current, 'a'.repeat(40), {
root: current.buildWorkspace!,
created: false,
needsInstall: true,
})
).toBe(false);
});
});
@@ -18,12 +18,14 @@ describe('buildRetryOperationPayload', () => {
{
install: { scenarioId: 903 },
installOperationId: 'original-install-generation',
releaseSource: { mode: 'BRANCH', ref: 'main' },
},
'newer-failed-operation'
)
).toEqual({
install: { scenarioId: 903 },
installOperationId: 'original-install-generation',
releaseSource: { mode: 'BRANCH', ref: 'main' },
});
});
});
@@ -507,7 +507,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await expect(page.getByTestId('server-operations-page')).toBeVisible();
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/);
await expect(page.getByTestId('source-current')).toBeChecked();
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
await expect(page.getByTestId('source-help')).toContainText('브랜치를 추적하면 작업 시작 시 최신 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
await expect(page.getByTestId('request-reset')).toBeEnabled();
await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오');
@@ -1176,7 +1176,7 @@ test('renders the stable server identity without exposing the default suffix as
expect(state.profileNavigationRequests).toBe(1);
});
test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
test('scenario-only operator resets the server-selected version without Git or Gateway controls', async ({ page }) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
@@ -211,7 +211,7 @@ const pageDescription = computed(() => {
return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.';
}
if (props.mode === 'scenario') {
return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.';
return '서버에 지정된 브랜치의 최신 버전 또는 고정 커밋으로 시나리오를 초기화합니다.';
}
return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.';
});
@@ -227,7 +227,7 @@ const activeOperation = computed(
const sourceHelp = computed(() =>
form.sourceMode === 'CURRENT'
? '현재 서버에 배포된 커밋의 시나리오 리소스를 사용합니다.'
? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.'
: form.sourceMode === 'BRANCH'
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
@@ -597,7 +597,7 @@ const requestReset = async () => {
}
const scenarioId = form.scenarioId;
const sourceLabel =
form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
if (
!window.confirm(
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}`
@@ -925,7 +925,7 @@ onBeforeUnmount(() => {
value="CURRENT"
data-testid="source-current"
/>
현재 배포 버전
서버 지정 버전
</label>
<label
v-if="mode === 'version' || hasCapability('admin.profiles.deploy')"
+11 -11
View File
@@ -15,7 +15,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 현재 배포 버전 또는 새 버전으로 현 시즌 DB와 시나리오 교체 |
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 |
| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 |
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
@@ -63,7 +63,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다.
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
- 서버 지정 버전의 시나리오 catalog는 capability·operation polling batch와
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
@@ -95,15 +95,15 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
## Profile 권한 분류
| capability | 허용 작업 |
| -------------------------------- | --------------------------------------------------------- |
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개, 시간 조정과 현재 기수 게임 옵션 |
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
| capability | 허용 작업 |
| -------------------------------- | ---------------------------------------------------------- |
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개, 시간 조정과 현재 기수 게임 옵션 |
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 서버 지정 branch 최신 또는 고정 commit으로 시나리오 초기화 |
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
+10 -2
View File
@@ -100,12 +100,20 @@ Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습
### 시나리오 초기화
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때
사용합니다. 기본 `현재 배포 버전`profile의 게시된 full commit을 서버에서
결정하므로 Git 입력과 `admin.profiles.deploy` 권한이 필요하지 않습니다. 새 branch
사용합니다. 기본 `서버 지정 버전`마지막으로 성공한 상위 권한자의 배포 소스를
따릅니다. Branch가 지정되어 있으면 worker가 작업을 claim할 때 원격 최신 commit을
해석하고, commit이 지정되어 있으면 그 full SHA를 계속 사용합니다. 따라서 Git 입력과
`admin.profiles.deploy` 권한 없이도 일반 초기화 권한자가 branch 업데이트를 포함해
초기화할 수 있습니다. 새 branch
또는 commit을 함께 배포하는 모드는 `admin.scenarios.reset`
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
NPC와 자동 진행 설정을 확인하고 요청해 주세요.
같은 commit이 이미 active profile workspace에 설치되어 있으면 build command는 생략하고
migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업데이트에서 commit을
선택한 경우에만 이후 일반 초기화가 그 commit에 고정됩니다. 다시 branch를 선택해
성공적으로 배포하면 branch 추적으로 돌아갑니다.
턴 간격과 고급 옵션은 서버 상태 화면에서 저장한
`GatewayProfile.meta.resetDefaults`를 최초값으로 사용합니다. 서버별 기본값을
바꾸려면 `admin.profiles.settings:<name>` 권한으로 메타를 저장하고 시나리오
@@ -0,0 +1,36 @@
-- Preserve the source policy of the release that produced the active profile build.
-- BRANCH keeps following its remote head; COMMIT remains explicitly pinned.
WITH latest_success AS (
SELECT DISTINCT ON ("profile_name")
"profile_name",
"source_mode",
"source_ref",
"resolved_commit_sha"
FROM "gateway_operation"
WHERE "status" = 'SUCCEEDED'
AND (
"type" = 'DEPLOY'
OR ("type" = 'RESET' AND "payload" ->> 'requestedSource' IN ('BRANCH', 'COMMIT'))
)
AND "source_mode" IS NOT NULL
AND "source_ref" IS NOT NULL
AND "resolved_commit_sha" IS NOT NULL
ORDER BY "profile_name", "completed_at" DESC NULLS LAST, "created_at" DESC
)
UPDATE "gateway_profile" AS profile
SET "meta" = jsonb_set(
COALESCE(profile."meta", '{}'::jsonb),
'{releaseSource}',
jsonb_build_object(
'mode', latest."source_mode"::text,
'ref', CASE
WHEN latest."source_mode" = 'BRANCH' THEN latest."source_ref"
ELSE latest."resolved_commit_sha"
END
),
true
)
FROM latest_success AS latest
WHERE profile."profile_name" = latest."profile_name"
AND profile."build_commit_sha" = latest."resolved_commit_sha"
AND NOT (COALESCE(profile."meta", '{}'::jsonb) ? 'releaseSource');
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260818001000_add_game_cancellation_operation",
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}