diff --git a/README.md b/README.md
index 43b5ad73..db4c9008 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
-식별합니다.
+식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 주기적으로 확인하고,
+현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 toast로 한 번 안내합니다.
## 개발 환경
diff --git a/app/game-frontend/e2e/deploymentVersionNotice.spec.ts b/app/game-frontend/e2e/deploymentVersionNotice.spec.ts
new file mode 100644
index 00000000..f8a40e43
--- /dev/null
+++ b/app/game-frontend/e2e/deploymentVersionNotice.spec.ts
@@ -0,0 +1,64 @@
+import { expect, test, type Page } from '@playwright/test';
+import { gamePath } from './gameTestPaths.js';
+
+const currentCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567';
+const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
+const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
+
+const installVersionFixture = async (page: Page) => {
+ let availableCommitSha = currentCommitSha;
+ let requests = 0;
+ await page.route('**/deployment-version.json*', async (route) => {
+ requests += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ commitSha: availableCommitSha }),
+ });
+ });
+ return {
+ deployNextVersion: () => {
+ availableCommitSha = nextCommitSha;
+ },
+ requestCount: () => requests,
+ };
+};
+
+for (const viewport of [
+ { name: 'desktop', width: 1280, height: 800 },
+ { name: 'mobile', width: 390, height: 844 },
+]) {
+ test(`shows one quiet update toast without forcing reload on ${viewport.name}`, async ({ page }) => {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ const fixture = await installVersionFixture(page);
+ await page.goto(gamePath('/version-notice-fixture'));
+ await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
+ await expect.poll(fixture.requestCount).toBeGreaterThan(0);
+ await page.evaluate(() => {
+ Object.assign(window, { __versionNoticePageMarker: 'kept' });
+ });
+
+ fixture.deployNextVersion();
+ await expect
+ .poll(async () => {
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+ return fixture.requestCount();
+ })
+ .toBeGreaterThan(1);
+ const toast = page.getByTestId('game-toast').filter({ hasText: noticeMessage });
+ await expect(toast).toBeVisible();
+ await expect(toast).toHaveAttribute('data-feedback-kind', 'info');
+ await expect(toast).toHaveCSS('transform', 'none');
+ const box = await toast.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.y).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width);
+ expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height);
+ expect(await page.evaluate(() => Reflect.get(window, '__versionNoticePageMarker'))).toBe('kept');
+
+ await page.getByRole('button', { name: '알림 닫기' }).click();
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+ await expect(toast).toHaveCount(0);
+ });
+}
diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs
index 55766660..5f422500 100644
--- a/app/game-frontend/e2e/playwright.config.mjs
+++ b/app/game-frontend/e2e/playwright.config.mjs
@@ -42,6 +42,7 @@ export default defineConfig({
'session-auth.spec.ts',
'npcPossession.spec.ts',
'joinLayout.spec.ts',
+ 'deploymentVersionNotice.spec.ts',
],
fullyParallel: false,
workers: 1,
diff --git a/app/game-frontend/src/App.vue b/app/game-frontend/src/App.vue
index 6baf484f..eae06717 100644
--- a/app/game-frontend/src/App.vue
+++ b/app/game-frontend/src/App.vue
@@ -1,6 +1,9 @@
diff --git a/app/game-frontend/src/composables/useDeploymentVersionNotice.ts b/app/game-frontend/src/composables/useDeploymentVersionNotice.ts
new file mode 100644
index 00000000..9f1f9299
--- /dev/null
+++ b/app/game-frontend/src/composables/useDeploymentVersionNotice.ts
@@ -0,0 +1,45 @@
+import { onBeforeUnmount, onMounted } from 'vue';
+import { createDeploymentVersionChecker } from '../config/deploymentVersion';
+import { useGameFeedback } from './useGameFeedback';
+
+const pollIntervalMs = 60_000;
+const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
+
+const resolveSessionStorage = (): Pick | undefined => {
+ try {
+ return window.sessionStorage;
+ } catch {
+ return undefined;
+ }
+};
+
+export const useDeploymentVersionNotice = (): void => {
+ const currentCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() ?? '';
+ const versionUrl = `${import.meta.env.BASE_URL}deployment-version.json`;
+ const { info: showInfoToast } = useGameFeedback();
+ const checker = createDeploymentVersionChecker({
+ currentCommitSha,
+ versionUrl,
+ storage: resolveSessionStorage(),
+ onVersionChanged: () => showInfoToast(noticeMessage),
+ });
+ let pollTimer: ReturnType | null = null;
+
+ const checkWhenVisible = (): void => {
+ if (document.visibilityState === 'visible') void checker.check();
+ };
+
+ onMounted(() => {
+ void checker.check();
+ pollTimer = setInterval(checkWhenVisible, pollIntervalMs);
+ document.addEventListener('visibilitychange', checkWhenVisible);
+ window.addEventListener('online', checkWhenVisible);
+ });
+
+ onBeforeUnmount(() => {
+ if (pollTimer) clearInterval(pollTimer);
+ pollTimer = null;
+ document.removeEventListener('visibilitychange', checkWhenVisible);
+ window.removeEventListener('online', checkWhenVisible);
+ });
+};
diff --git a/app/game-frontend/src/config/deploymentVersion.ts b/app/game-frontend/src/config/deploymentVersion.ts
new file mode 100644
index 00000000..288f34ab
--- /dev/null
+++ b/app/game-frontend/src/config/deploymentVersion.ts
@@ -0,0 +1,78 @@
+const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
+
+type VersionStorage = Pick;
+
+export type DeploymentVersionCheckerOptions = {
+ currentCommitSha: string;
+ versionUrl: string;
+ fetchVersion?: typeof fetch;
+ storage?: VersionStorage;
+ now?: () => number;
+ onVersionChanged: (availableCommitSha: string) => void;
+};
+
+export const deploymentVersionAssetSource = (buildCommitSha: string): string =>
+ `${JSON.stringify({ commitSha: buildCommitSha })}\n`;
+
+export const parseDeploymentCommitSha = (payload: unknown): string | null => {
+ if (!payload || typeof payload !== 'object' || !('commitSha' in payload)) return null;
+ const commitSha = String(payload.commitSha).trim().toLowerCase();
+ return fullCommitShaPattern.test(commitSha) ? commitSha : null;
+};
+
+const notificationStorageKey = (versionUrl: string, availableCommitSha: string): string =>
+ `sammo:deployment-version-notice:${versionUrl}:${availableCommitSha}`;
+
+export const createDeploymentVersionChecker = (options: DeploymentVersionCheckerOptions) => {
+ const currentCommitSha = options.currentCommitSha.trim().toLowerCase();
+ const fetchVersion = options.fetchVersion ?? fetch;
+ const now = options.now ?? Date.now;
+ let inFlight: Promise | null = null;
+ let lastNotifiedCommitSha: string | null = null;
+
+ const wasNotified = (key: string): boolean => {
+ try {
+ return options.storage?.getItem(key) === '1';
+ } catch {
+ return false;
+ }
+ };
+
+ const rememberNotification = (key: string): void => {
+ try {
+ options.storage?.setItem(key, '1');
+ } catch {
+ // Session storage can be unavailable under restrictive browser policies.
+ }
+ };
+
+ const run = async (): Promise => {
+ if (!fullCommitShaPattern.test(currentCommitSha)) return;
+ const separator = options.versionUrl.includes('?') ? '&' : '?';
+ const response = await fetchVersion(`${options.versionUrl}${separator}t=${now()}`, {
+ cache: 'no-store',
+ headers: { 'Cache-Control': 'no-cache' },
+ });
+ if (!response.ok) return;
+ const availableCommitSha = parseDeploymentCommitSha(await response.json());
+ if (!availableCommitSha || availableCommitSha === currentCommitSha) return;
+
+ const storageKey = notificationStorageKey(options.versionUrl, availableCommitSha);
+ if (lastNotifiedCommitSha === availableCommitSha || wasNotified(storageKey)) return;
+ lastNotifiedCommitSha = availableCommitSha;
+ rememberNotification(storageKey);
+ options.onVersionChanged(availableCommitSha);
+ };
+
+ return {
+ check: (): Promise => {
+ if (inFlight) return inFlight;
+ inFlight = run()
+ .catch(() => undefined)
+ .finally(() => {
+ inFlight = null;
+ });
+ return inFlight;
+ },
+ };
+};
diff --git a/app/game-frontend/test/deploymentVersionNotice.test.ts b/app/game-frontend/test/deploymentVersionNotice.test.ts
new file mode 100644
index 00000000..47bae45f
--- /dev/null
+++ b/app/game-frontend/test/deploymentVersionNotice.test.ts
@@ -0,0 +1,105 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ createDeploymentVersionChecker,
+ deploymentVersionAssetSource,
+ parseDeploymentCommitSha,
+} from '../src/config/deploymentVersion.ts';
+
+const currentCommitSha = '0123456789abcdef0123456789abcdef01234567';
+const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
+const laterCommitSha = 'fedcba9876543210fedcba9876543210fedcba98';
+
+const jsonResponse = (commitSha: string): Response =>
+ new Response(JSON.stringify({ commitSha }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+void test('accepts only full hexadecimal deployment commit values', () => {
+ assert.equal(parseDeploymentCommitSha({ commitSha: nextCommitSha.toUpperCase() }), nextCommitSha);
+ assert.equal(parseDeploymentCommitSha({ commitSha: 'main' }), null);
+ assert.equal(parseDeploymentCommitSha({ version: nextCommitSha }), null);
+ assert.equal(parseDeploymentCommitSha(null), null);
+});
+
+void test('exposes only the read-only build commit in the deployment version asset', () => {
+ assert.deepEqual(JSON.parse(deploymentVersionAssetSource(currentCommitSha)), { commitSha: currentCommitSha });
+});
+
+void test('notifies once per available version and bypasses browser caches', async () => {
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
+ const stored = new Map();
+ const notifications: string[] = [];
+ let availableCommitSha = currentCommitSha;
+ const checker = createDeploymentVersionChecker({
+ currentCommitSha,
+ versionUrl: '/che/deployment-version.json',
+ now: () => 1234,
+ storage: {
+ getItem: (key) => stored.get(key) ?? null,
+ setItem: (key, value) => void stored.set(key, value),
+ },
+ fetchVersion: async (url, init) => {
+ requests.push({ url: String(url), init });
+ return jsonResponse(availableCommitSha);
+ },
+ onVersionChanged: (commitSha) => notifications.push(commitSha),
+ });
+
+ await checker.check();
+ availableCommitSha = nextCommitSha;
+ await checker.check();
+ await checker.check();
+ availableCommitSha = laterCommitSha;
+ await checker.check();
+
+ assert.deepEqual(notifications, [nextCommitSha, laterCommitSha]);
+ assert.equal(requests.every(({ url }) => url === '/che/deployment-version.json?t=1234'), true);
+ assert.equal(requests.every(({ init }) => init?.cache === 'no-store'), true);
+ assert.equal(requests.every(({ init }) => new Headers(init?.headers).get('Cache-Control') === 'no-cache'), true);
+});
+
+void test('shares the once-only notice within the current tab session', async () => {
+ const stored = new Map();
+ const storage = {
+ getItem: (key: string) => stored.get(key) ?? null,
+ setItem: (key: string, value: string) => void stored.set(key, value),
+ };
+ let notifications = 0;
+ const options = {
+ currentCommitSha,
+ versionUrl: '/hwe/deployment-version.json',
+ storage,
+ fetchVersion: async () => jsonResponse(nextCommitSha),
+ onVersionChanged: () => notifications++,
+ };
+
+ await createDeploymentVersionChecker(options).check();
+ await createDeploymentVersionChecker(options).check();
+
+ assert.equal(notifications, 1);
+});
+
+void test('ignores unavailable or malformed version documents without disrupting the page', async () => {
+ let notifications = 0;
+ const unavailable = createDeploymentVersionChecker({
+ currentCommitSha,
+ versionUrl: '/che/deployment-version.json',
+ fetchVersion: async () => {
+ throw new Error('offline');
+ },
+ onVersionChanged: () => notifications++,
+ });
+ const malformed = createDeploymentVersionChecker({
+ currentCommitSha,
+ versionUrl: '/che/deployment-version.json',
+ fetchVersion: async () => new Response('{', { status: 200 }),
+ onVersionChanged: () => notifications++,
+ });
+
+ await unavailable.check();
+ await malformed.check();
+ assert.equal(notifications, 0);
+});
diff --git a/app/game-frontend/test/viteConfig.test.ts b/app/game-frontend/test/viteConfig.test.ts
index 10cc4167..18cec4d4 100644
--- a/app/game-frontend/test/viteConfig.test.ts
+++ b/app/game-frontend/test/viteConfig.test.ts
@@ -48,6 +48,17 @@ void describe('game frontend Vite config', () => {
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
JSON.stringify(commitSha.toLowerCase())
);
+ assert.equal(
+ loaded?.config.plugins?.some(
+ (plugin) =>
+ plugin !== null &&
+ typeof plugin === 'object' &&
+ !Array.isArray(plugin) &&
+ 'name' in plugin &&
+ plugin.name === 'sammo-deployment-version'
+ ),
+ true
+ );
} finally {
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
diff --git a/app/game-frontend/vite.config.ts b/app/game-frontend/vite.config.ts
index 36e2526b..dfb2ae00 100644
--- a/app/game-frontend/vite.config.ts
+++ b/app/game-frontend/vite.config.ts
@@ -1,9 +1,10 @@
-import { defineConfig, loadEnv } from 'vite';
+import { defineConfig, loadEnv, type Plugin } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import { execFileSync } from 'node:child_process';
import path from 'path';
-import { mergeViteEnv } from './src/config/viteEnv';
+import { deploymentVersionAssetSource } from './src/config/deploymentVersion.ts';
+import { mergeViteEnv } from './src/config/viteEnv.ts';
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
@@ -24,6 +25,17 @@ export const resolveBuildCommitSha = (explicitSha: string | undefined, repositor
}
};
+export const createDeploymentVersionPlugin = (buildCommitSha: string): Plugin => ({
+ name: 'sammo-deployment-version',
+ generateBundle() {
+ this.emitFile({
+ type: 'asset',
+ fileName: 'deployment-version.json',
+ source: deploymentVersionAssetSource(buildCommitSha),
+ });
+ },
+});
+
const normalizeBasePath = (value: string | undefined): string => {
const pathValue = (value ?? '/').trim();
if (!pathValue || pathValue === '/') {
@@ -50,7 +62,7 @@ export default defineConfig(({ mode }) => {
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
return {
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
- plugins: [vue(), tailwindcss()],
+ plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
define: {
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
},
diff --git a/app/gateway-api/test/profileFrontendMaterializer.test.ts b/app/gateway-api/test/profileFrontendMaterializer.test.ts
index 26e77f62..7cfb5afd 100644
--- a/app/gateway-api/test/profileFrontendMaterializer.test.ts
+++ b/app/gateway-api/test/profileFrontendMaterializer.test.ts
@@ -23,11 +23,15 @@ describe('profile frontend materializer', () => {
await mkdir(source, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(path.join(source, 'index.html'), 'new release');
+ await writeFile(path.join(source, 'deployment-version.json'), '{"commitSha":"new"}\n');
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');
+ await expect(readFile(path.join(target, 'deployment-version.json'), 'utf8')).resolves.toBe(
+ '{"commitSha":"new"}\n'
+ );
});
it('keeps an existing artifact untouched when the cached build is missing', async () => {
diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md
index aeeb1c56..b5cd72bd 100644
--- a/docs/architecture/runtime.md
+++ b/docs/architecture/runtime.md
@@ -136,6 +136,16 @@ Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
읽을 수 없을 때만 `unknown`을 표시합니다.
+같은 build 단계는 profile root에 `deployment-version.json`을 생성합니다. 이미 열린
+game frontend는 bundle에 고정된 commit과 이 정적 문서의 commit을 최초 mount,
+60초 주기, tab visibility 복귀와 network online 복귀 때 비교합니다. 다른 full SHA를
+처음 관찰하면 공용 info toast로 새로고침 안내만 하고 reload를 강제하지 않습니다.
+조회에는 cache-busting query와 `cache: no-store`를 사용하며, 실패·잘못된 문서는
+현재 화면을 방해하지 않고 무시합니다. 알림 여부는 tab session storage에만 남으므로
+Gateway/game DB, Redis, 인증, 턴 처리에는 mutation이 없습니다. Profile `DEPLOY`가
+API·engine·frontend를 같은 commit worktree에서 함께 전환하므로 이 frontend 문서는
+해당 profile의 새 backend/frontend release가 readiness를 통과해 실제 서빙되기 시작한
+뒤에만 달라집니다.
`RESET` operation은 같은 build 경계를 사용한 뒤 현재 시즌 테이블을 seed로
교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`,
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
diff --git a/docs/release-operations.md b/docs/release-operations.md
index 1537a1bc..40d813fa 100644
--- a/docs/release-operations.md
+++ b/docs/release-operations.md
@@ -140,6 +140,11 @@ path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
`.release-dist//game-frontend`에 staging directory를 거쳐 교체합니다.
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
+각 artifact의 `deployment-version.json`은 bundle과 같은 full commit SHA만 담습니다.
+열린 profile 탭은 이 파일을 cache 우회로 확인해 bundle SHA와 달라졌을 때 한 번만
+`새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를 표시합니다.
+서버는 reload를 강제하지 않으며 이 확인은 정적 GET과 tab session storage만 사용해
+game/Gateway DB와 Redis에 영향을 주지 않습니다.
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
server target을 `game-api`로 제한합니다.