feat: add guarded GitHub Pages deployment

This commit is contained in:
2026-08-31 12:42:59 +09:00
parent 89169c5c0d
commit 944da34c45
10 changed files with 268 additions and 1 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules
.svelte-kit
build
offline-dist
pages-dist
release
.env
.env.*
+39
View File
@@ -0,0 +1,39 @@
# GitHub Pages 배포
원본 저장소와 전체 게임 데이터는 개인 Git 서버에만 보관합니다. GitHub의 `DD4SN/DD4SN.github.io` 비공개 저장소에는 공개 데이터 검증을 통과한 `pages-dist` 산출물과 Pages 배포 워크플로만 전송합니다.
## 공개 데이터 게이트
`pages/publication-policy.json`은 Pages에 공개해도 되는 HolodoriDB 마스터 데이터 버전과 최초 공개 가능 시각을 고정합니다.
- 카드, 보드, 메모리 데이터 버전이 승인 버전과 다르면 빌드를 중단합니다.
- 공개 시각이 지나지 않았거나 공개 시각이 없는 악곡 채보가 있으면 빌드를 중단합니다.
- 시작 시각이 지나지 않았거나 시작 시각이 없는 이벤트가 있으면 빌드를 중단합니다.
- Pages 빌드는 서버 시간 API를 호출하지 않고 브라우저 시각을 사용합니다.
새 마스터 데이터를 수집한 뒤에는 포함된 카드, 보드와 메모리 정보가 모두 공개 가능한지 검토한 후에만 정책의 `approvedMasterDataVersion`과 `approvedNotBefore`를 갱신합니다. 한 마스터 버전에 서로 다른 공개 시각의 데이터가 함께 있으면 가장 늦은 공개 시각까지 전체 Pages 빌드가 차단됩니다.
검증과 정적 빌드:
```powershell
npm run check:pages-data
npm run build:pages
```
테스트에서만 `PAGES_PUBLICATION_TIME`에 ISO-8601 시각을 지정할 수 있습니다. 실제 배포에서는 설정하지 않아 신뢰할 수 있는 CI 실행 시각을 사용합니다.
## 배포
```powershell
npm run deploy:pages
```
배포 스크립트는 다음 순서로 동작합니다.
1. 공개 데이터 검증과 `pages-dist` 빌드
2. 임시 디렉터리에 `DD4SN/DD4SN.github.io` 저장소 복제
3. `site/`에 정적 산출물 복사
4. `.github/workflows/deploy.yml` 갱신
5. 배포 저장소의 `main` 브랜치에 산출물 커밋과 푸시
개인 Git 서버의 예약 작업에서 실행할 때는 GitHub 저장소에만 쓰기 가능한 최소 권한 자격증명을 사용합니다. 원본 저장소를 GitHub 원격으로 추가하거나 원본 Git 이력을 Pages 저장소로 푸시하지 않습니다.
+3
View File
@@ -10,6 +10,9 @@
"dev": "vite dev --host 0.0.0.0 --port 5173",
"build": "vite build",
"build:offline": "vite build --config vite.offline.config.ts && node scripts/finalize-offline-build.mjs",
"check:pages-data": "node scripts/verify-pages-public-data.mjs",
"build:pages": "npm run check:pages-data && vite build --config vite.pages.config.ts && node scripts/finalize-offline-build.mjs pages-dist",
"deploy:pages": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-pages.ps1",
"package:offline": "npm run build:offline && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-offline.ps1",
"preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+34
View File
@@ -0,0 +1,34 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload site
uses: actions/upload-pages-artifact@v4
with:
path: site
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+6
View File
@@ -0,0 +1,6 @@
{
"schemaVersion": 1,
"repository": "https://github.com/DD4SN/DD4SN.github.io.git",
"approvedMasterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0",
"approvedNotBefore": "2026-08-29T15:00:00.000Z"
}
+25
View File
@@ -0,0 +1,25 @@
export type ReleaseClockSource = 'system';
export type ReleaseClock = {
source: ReleaseClockSource;
now: () => number;
};
type ReleaseClockOptions = {
wallNow?: () => number;
};
function systemClock(wallNow: () => number): ReleaseClock {
return { source: 'system', now: wallNow };
}
export function createInitialReleaseClock(
_serverNow: unknown,
options: ReleaseClockOptions = {}
): ReleaseClock {
return systemClock(options.wallNow ?? Date.now);
}
export async function synchronizeReleaseClock(options: ReleaseClockOptions = {}): Promise<ReleaseClock> {
return systemClock(options.wallNow ?? Date.now);
}
+66
View File
@@ -0,0 +1,66 @@
param(
[string]$Repository = 'https://github.com/DD4SN/DD4SN.github.io.git'
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$siteSource = Join-Path $projectRoot 'pages-dist'
$workflowSource = Join-Path $projectRoot 'pages/deploy.yml'
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("holodori-pages-{0}" -f [guid]::NewGuid().ToString('N'))
$checkout = Join-Path $temporaryRoot 'repository'
try {
& npm --prefix $projectRoot run build:pages
if ($LASTEXITCODE -ne 0) { throw 'Pages build failed.' }
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
& git clone $Repository $checkout
if ($LASTEXITCODE -ne 0) { throw 'Pages repository clone failed.' }
$resolvedCheckout = (Resolve-Path -LiteralPath $checkout).Path
$resolvedTemporaryRoot = (Resolve-Path -LiteralPath $temporaryRoot).Path
if (-not $resolvedCheckout.StartsWith($resolvedTemporaryRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
throw 'Refusing to update a checkout outside the temporary deployment directory.'
}
$siteTarget = Join-Path $resolvedCheckout 'site'
if (Test-Path -LiteralPath $siteTarget) {
Remove-Item -LiteralPath $siteTarget -Recurse -Force
}
New-Item -ItemType Directory -Path $siteTarget | Out-Null
Copy-Item -Path (Join-Path $siteSource '*') -Destination $siteTarget -Recurse -Force
New-Item -ItemType File -Path (Join-Path $siteTarget '.nojekyll') -Force | Out-Null
$workflowTarget = Join-Path $resolvedCheckout '.github/workflows'
New-Item -ItemType Directory -Path $workflowTarget -Force | Out-Null
Copy-Item -LiteralPath $workflowSource -Destination (Join-Path $workflowTarget 'deploy.yml') -Force
Push-Location $resolvedCheckout
try {
& git add --all -- site .github/workflows/deploy.yml
if ($LASTEXITCODE -ne 0) { throw 'Failed to stage Pages files.' }
& git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Output 'GitHub Pages output is already up to date.'
return
}
$package = Get-Content -LiteralPath (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json
& git commit -m ("deploy: HolodoriCalc v{0}" -f $package.version)
if ($LASTEXITCODE -ne 0) { throw 'Pages commit failed.' }
& git push origin HEAD:main
if ($LASTEXITCODE -ne 0) { throw 'Pages push failed.' }
} finally {
Pop-Location
}
} finally {
if (Test-Path -LiteralPath $temporaryRoot) {
$resolvedTemporaryRoot = (Resolve-Path -LiteralPath $temporaryRoot).Path
$systemTemporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\')
if ($resolvedTemporaryRoot.StartsWith($systemTemporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -and
(Split-Path -Leaf $resolvedTemporaryRoot).StartsWith('holodori-pages-')) {
Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
const output = path.resolve('offline-dist');
const output = path.resolve(process.argv[2] ?? 'offline-dist');
const htmlPath = path.join(output, 'index.html');
const scriptPath = path.join(output, 'assets', 'app.js');
const stylePath = path.join(output, 'assets', 'app.css');
+57
View File
@@ -0,0 +1,57 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(projectRoot, relativePath), 'utf8'));
}
function publicationTime() {
const configured = process.env.PAGES_PUBLICATION_TIME;
const value = configured ? Date.parse(configured) : Date.now();
if (!Number.isFinite(value)) throw new Error('PAGES_PUBLICATION_TIME must be a valid ISO-8601 date.');
return value;
}
const now = publicationTime();
const policy = await readJson('pages/publication-policy.json');
const cards = await readJson('src/lib/data/cards-summary.json');
const boards = await readJson('src/lib/data/boards-summary.json');
const memory = await readJson('src/lib/data/memory-summary.json');
const charts = await readJson('src/lib/data/exact-chart-catalog.json');
const { events } = await import(pathToFileURL(path.join(projectRoot, 'src/lib/data/events.ts')).href);
const approvedVersion = policy.approvedMasterDataVersion;
const versions = new Map([
['cards-summary.json', cards.masterDataVersion],
['boards-summary.json', boards.masterDataVersion],
['memory-summary.json', memory.masterDataVersion]
]);
const mismatchedVersions = [...versions].filter(([, version]) => version !== approvedVersion);
if (mismatchedVersions.length > 0) {
throw new Error(`Unapproved master data: ${mismatchedVersions.map(([file, version]) => `${file}=${version}`).join(', ')}`);
}
const approvedNotBefore = Date.parse(policy.approvedNotBefore);
if (!Number.isFinite(approvedNotBefore) || now < approvedNotBefore) {
throw new Error(`The approved master data cannot be published before ${policy.approvedNotBefore}.`);
}
const unreleasedCharts = charts.charts.filter((chart) => !Number.isFinite(chart.releaseTime) || chart.releaseTime > now);
if (unreleasedCharts.length > 0) {
throw new Error(`Pages build contains ${unreleasedCharts.length} unreleased or undated charts: ${unreleasedCharts.map((chart) => `${chart.songId}/${chart.difficulty}`).join(', ')}`);
}
const unreleasedEvents = events.filter((event) => {
const startsAt = Date.parse(event.startsAt);
return !Number.isFinite(startsAt) || startsAt > now;
});
if (unreleasedEvents.length > 0) {
throw new Error(`Pages build contains unreleased or undated events: ${unreleasedEvents.map((event) => event.id).join(', ')}`);
}
process.stdout.write(`Pages public-data gate passed at ${new Date(now).toISOString()}.\n`);
process.stdout.write(`Approved master data: ${approvedVersion}; cards=${cards.cards.length}; charts=${charts.charts.length}; events=${events.length}.\n`);
+36
View File
@@ -0,0 +1,36 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: path.join(root, 'offline'),
base: './',
publicDir: path.join(root, 'static'),
plugins: [svelte()],
resolve: {
alias: [
{ find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') },
{ find: '$app/environment', replacement: path.join(root, 'offline/app-environment.ts') },
{ find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') },
{ find: '$lib/data/release-clock.ts', replacement: path.join(root, 'pages/release-clock.ts') },
{ find: '$lib', replacement: path.join(root, 'src/lib') }
]
},
worker: { format: 'iife' },
build: {
outDir: path.join(root, 'pages-dist'),
emptyOutDir: true,
cssCodeSplit: false,
rollupOptions: {
output: {
format: 'iife',
entryFileNames: 'assets/app.js',
assetFileNames: (assetInfo) => assetInfo.name?.endsWith('.css') ? 'assets/app.css' : 'assets/[name][extname]'
}
},
chunkSizeWarningLimit: 20000
}
});