From 944da34c4514c582f1978886469dbd0838ee754d Mon Sep 17 00:00:00 2001 From: dal4segno Date: Mon, 31 Aug 2026 12:42:59 +0900 Subject: [PATCH] feat: add guarded GitHub Pages deployment --- .gitignore | 1 + docs/github-pages-deployment.md | 39 ++++++++++++++++ package.json | 3 ++ pages/deploy.yml | 34 ++++++++++++++ pages/publication-policy.json | 6 +++ pages/release-clock.ts | 25 +++++++++++ scripts/deploy-pages.ps1 | 66 ++++++++++++++++++++++++++++ scripts/finalize-offline-build.mjs | 2 +- scripts/verify-pages-public-data.mjs | 57 ++++++++++++++++++++++++ vite.pages.config.ts | 36 +++++++++++++++ 10 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 docs/github-pages-deployment.md create mode 100644 pages/deploy.yml create mode 100644 pages/publication-policy.json create mode 100644 pages/release-clock.ts create mode 100644 scripts/deploy-pages.ps1 create mode 100644 scripts/verify-pages-public-data.mjs create mode 100644 vite.pages.config.ts diff --git a/.gitignore b/.gitignore index 9c23796..c57a4ce 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .svelte-kit build offline-dist +pages-dist release .env .env.* diff --git a/docs/github-pages-deployment.md b/docs/github-pages-deployment.md new file mode 100644 index 0000000..520c2d0 --- /dev/null +++ b/docs/github-pages-deployment.md @@ -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 저장소로 푸시하지 않습니다. diff --git a/package.json b/package.json index d827d9e..101db2b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/deploy.yml b/pages/deploy.yml new file mode 100644 index 0000000..84cbbe6 --- /dev/null +++ b/pages/deploy.yml @@ -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 diff --git a/pages/publication-policy.json b/pages/publication-policy.json new file mode 100644 index 0000000..7c4df18 --- /dev/null +++ b/pages/publication-policy.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "repository": "https://github.com/DD4SN/DD4SN.github.io.git", + "approvedMasterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0", + "approvedNotBefore": "2026-08-29T15:00:00.000Z" +} diff --git a/pages/release-clock.ts b/pages/release-clock.ts new file mode 100644 index 0000000..079ea85 --- /dev/null +++ b/pages/release-clock.ts @@ -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 { + return systemClock(options.wallNow ?? Date.now); +} diff --git a/scripts/deploy-pages.ps1 b/scripts/deploy-pages.ps1 new file mode 100644 index 0000000..9559c91 --- /dev/null +++ b/scripts/deploy-pages.ps1 @@ -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 + } + } +} diff --git a/scripts/finalize-offline-build.mjs b/scripts/finalize-offline-build.mjs index eedff36..416adbb 100644 --- a/scripts/finalize-offline-build.mjs +++ b/scripts/finalize-offline-build.mjs @@ -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'); diff --git a/scripts/verify-pages-public-data.mjs b/scripts/verify-pages-public-data.mjs new file mode 100644 index 0000000..5c50b85 --- /dev/null +++ b/scripts/verify-pages-public-data.mjs @@ -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`); diff --git a/vite.pages.config.ts b/vite.pages.config.ts new file mode 100644 index 0000000..ff43917 --- /dev/null +++ b/vite.pages.config.ts @@ -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 + } +});