feat: add guarded GitHub Pages deployment
This commit is contained in:
@@ -2,6 +2,7 @@ node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
offline-dist
|
||||
pages-dist
|
||||
release
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -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 저장소로 푸시하지 않습니다.
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"repository": "https://github.com/DD4SN/DD4SN.github.io.git",
|
||||
"approvedMasterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0",
|
||||
"approvedNotBefore": "2026-08-29T15:00:00.000Z"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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`);
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user