feat: profile 새 버전 안내 toast를 추가한다
배포 bundle과 같은 commit의 정적 버전 문서를 생성하고, 열린 탭이 DB mutation 없이 변경을 감지하도록 한다. 동일 버전은 tab session에서 한 번만 안내하며 강제 새로고침은 하지 않는다.
This commit is contained in:
@@ -85,7 +85,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
|
|||||||
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
|
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
|
||||||
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
|
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
|
||||||
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
|
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
|
||||||
식별합니다.
|
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 주기적으로 확인하고,
|
||||||
|
현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 toast로 한 번 안내합니다.
|
||||||
|
|
||||||
## 개발 환경
|
## 개발 환경
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export default defineConfig({
|
|||||||
'session-auth.spec.ts',
|
'session-auth.spec.ts',
|
||||||
'npcPossession.spec.ts',
|
'npcPossession.spec.ts',
|
||||||
'joinLayout.spec.ts',
|
'joinLayout.spec.ts',
|
||||||
|
'deploymentVersionNotice.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
||||||
|
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
||||||
|
|
||||||
|
useDeploymentVersionNotice();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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<Storage, 'getItem' | 'setItem'> | 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<typeof setInterval> | 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
|
||||||
|
|
||||||
|
type VersionStorage = Pick<Storage, 'getItem' | 'setItem'>;
|
||||||
|
|
||||||
|
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<void> | 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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = run()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
return inFlight;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<string, string>();
|
||||||
|
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<string, string>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -48,6 +48,17 @@ void describe('game frontend Vite config', () => {
|
|||||||
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
|
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
|
||||||
JSON.stringify(commitSha.toLowerCase())
|
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 {
|
} finally {
|
||||||
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
|
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
|
||||||
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
|
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig, loadEnv, type Plugin } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import path from 'path';
|
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;
|
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 normalizeBasePath = (value: string | undefined): string => {
|
||||||
const pathValue = (value ?? '/').trim();
|
const pathValue = (value ?? '/').trim();
|
||||||
if (!pathValue || pathValue === '/') {
|
if (!pathValue || pathValue === '/') {
|
||||||
@@ -50,7 +62,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
|
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
|
||||||
return {
|
return {
|
||||||
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
|
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
|
||||||
plugins: [vue(), tailwindcss()],
|
plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
|
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ describe('profile frontend materializer', () => {
|
|||||||
await mkdir(source, { recursive: true });
|
await mkdir(source, { recursive: true });
|
||||||
await mkdir(target, { recursive: true });
|
await mkdir(target, { recursive: true });
|
||||||
await writeFile(path.join(source, 'index.html'), 'new release');
|
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 writeFile(path.join(target, 'index.html'), 'old release');
|
||||||
|
|
||||||
await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot });
|
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, '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 () => {
|
it('keeps an existing artifact untouched when the cached build is missing', async () => {
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`
|
|||||||
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
|
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
|
||||||
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
|
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
|
||||||
읽을 수 없을 때만 `unknown`을 표시합니다.
|
읽을 수 없을 때만 `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로
|
`RESET` operation은 같은 build 경계를 사용한 뒤 현재 시즌 테이블을 seed로
|
||||||
교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`,
|
교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`,
|
||||||
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
|
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
|
|||||||
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
|
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
|
||||||
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
|
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
|
||||||
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
|
않고, 여러 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도 실행하므로 기존처럼
|
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
|
||||||
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
|
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
|
||||||
server target을 `game-api`로 제한합니다.
|
server target을 `game-api`로 제한합니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user