diff --git a/.gitea/workflows/deploy-pages.yml b/.gitea/workflows/deploy-pages.yml new file mode 100644 index 0000000..6b05d70 --- /dev/null +++ b/.gitea/workflows/deploy-pages.yml @@ -0,0 +1,55 @@ +name: Publish GitHub Pages output + +on: + push: + branches: [main] + schedule: + - cron: '*/5 * * * *' + workflow_dispatch: + +concurrency: + group: publish-github-pages + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check publication time + id: publication + shell: bash + run: | + set +e + node scripts/verify-pages-public-data.mjs + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + elif [ "$status" -eq 78 ]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "Public data is not due yet; skipping this scheduled deployment." + else + exit "$status" + fi + + - name: Build public site + if: steps.publication.outputs.ready == 'true' + run: npm run build:pages + + - name: Publish generated files + if: steps.publication.outputs.ready == 'true' + env: + GITHUB_PAGES_TOKEN: ${{ secrets.GITHUB_PAGES_TOKEN }} + run: npm run publish:pages diff --git a/docs/github-pages-deployment.md b/docs/github-pages-deployment.md index d49e600..982fe76 100644 --- a/docs/github-pages-deployment.md +++ b/docs/github-pages-deployment.md @@ -1,6 +1,6 @@ # GitHub Pages 배포 -원본 저장소와 전체 게임 데이터는 개인 Git 서버에만 보관합니다. GitHub의 `DD4SN/DD4SN.github.io` 비공개 저장소에는 공개 데이터 검증을 통과한 `pages-dist` 산출물과 Pages 배포 워크플로만 전송합니다. +원본 저장소와 전체 게임 데이터는 개인 Git 서버에만 보관합니다. 공개 GitHub 저장소 `DD4SN/DD4SN.github.io`에는 공개 데이터 검증을 통과한 `pages-dist` 산출물과 Pages 배포 워크플로만 전송합니다. ## 공개 데이터 게이트 @@ -39,3 +39,17 @@ npm run deploy:pages 최초 배포 전에 저장소의 Pages 소스를 GitHub Actions 방식으로 한 번 활성화해야 합니다. 개인 Git 서버의 예약 작업에서 실행할 때는 GitHub 저장소에만 쓰기 가능한 최소 권한 자격증명을 사용합니다. 원본 저장소를 GitHub 원격으로 추가하거나 원본 Git 이력을 Pages 저장소로 푸시하지 않습니다. + +## Gitea 예약 배포 + +`.gitea/workflows/deploy-pages.yml`은 `main` 갱신, 수동 실행, 5분 간격 예약 실행에서 공개 가능 여부를 확인합니다. 공개 시각 전이면 정상적으로 배포를 건너뛰고, 승인 버전 불일치나 날짜 누락 같은 정책 오류는 실패로 처리합니다. 검증을 통과하면 정적 사이트를 만든 뒤 생성 파일만 GitHub Pages 저장소 최상위에 커밋합니다. + +이 워크플로는 더 이상 사용하지 않는 서버용 온라인 빌드를 만들지 않습니다. 오프라인 빌드도 로컬 회귀 테스트용으로만 유지하며 GitHub Pages 배포에서는 `build:pages`만 실행합니다. + +사전 준비: + +1. Gitea에 `ubuntu-latest` 레이블을 처리할 수 있는 Actions runner를 연결합니다. +2. GitHub에서 `DD4SN/DD4SN.github.io` 저장소의 Contents 읽기/쓰기만 허용하는 fine-grained PAT를 발급합니다. +3. 개인 Gitea 저장소의 Actions secret `GITHUB_PAGES_TOKEN`에 PAT를 등록합니다. + +토큰은 원본 저장소 파일, 로그, 생성 사이트에 기록하지 않습니다. GitHub Pages 저장소에 변경이 없으면 새 커밋을 만들지 않습니다. diff --git a/package.json b/package.json index 101db2b..03a7a29 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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", + "publish:pages": "node scripts/publish-pages.mjs", "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/scripts/publish-pages.mjs b/scripts/publish-pages.mjs new file mode 100644 index 0000000..44224b7 --- /dev/null +++ b/scripts/publish-pages.mjs @@ -0,0 +1,81 @@ +import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const siteSource = path.join(projectRoot, 'pages-dist'); +const workflowSource = path.join(projectRoot, 'pages', 'deploy.yml'); +const repository = process.env.GITHUB_PAGES_REPOSITORY ?? 'https://github.com/DD4SN/DD4SN.github.io.git'; + +if (!process.env.GITHUB_PAGES_TOKEN) { + throw new Error('GITHUB_PAGES_TOKEN is required to publish Pages output.'); +} + +function runGit(args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn('git', args, { + cwd: options.cwd ?? projectRoot, + env: options.env ?? process.env, + stdio: options.quiet ? 'ignore' : 'inherit', + shell: false + }); + child.on('error', reject); + child.on('exit', (code) => resolve(code ?? 1)); + }); +} + +async function requireGit(args, options) { + const code = await runGit(args, options); + if (code !== 0) throw new Error(`git ${args[0]} failed with exit code ${code}.`); +} + +const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'holodori-pages-')); +const checkout = path.join(temporaryRoot, 'repository'); +const askPass = path.join(temporaryRoot, process.platform === 'win32' ? 'askpass.cmd' : 'askpass.sh'); +const askPassContents = process.platform === 'win32' + ? '@echo off\necho %1 | findstr /I "Username" >nul\nif %errorlevel%==0 (echo x-access-token) else (echo %GITHUB_PAGES_TOKEN%)\n' + : '#!/bin/sh\ncase "$1" in\n *Username*) printf "%s\\n" "x-access-token" ;;\n *) printf "%s\\n" "$GITHUB_PAGES_TOKEN" ;;\nesac\n'; + +try { + await writeFile(askPass, askPassContents, { mode: 0o700 }); + const gitEnvironment = { + ...process.env, + GIT_ASKPASS: askPass, + GIT_TERMINAL_PROMPT: '0' + }; + + await requireGit(['clone', repository, checkout], { env: gitEnvironment }); + + const generatedPaths = ['site', 'assets', 'icons', 'index.html', 'README.txt', 'VERSION.txt', '.nojekyll']; + await Promise.all(generatedPaths.map((generatedPath) => rm(path.join(checkout, generatedPath), { recursive: true, force: true }))); + await cp(siteSource, checkout, { recursive: true }); + await writeFile(path.join(checkout, '.nojekyll'), ''); + + const workflowTarget = path.join(checkout, '.github', 'workflows'); + await mkdir(workflowTarget, { recursive: true }); + await cp(workflowSource, path.join(workflowTarget, 'deploy.yml')); + + await requireGit(['config', 'user.name', 'HolodoriCalc deployment'], { cwd: checkout, env: gitEnvironment }); + await requireGit(['config', 'user.email', 'actions@users.noreply.github.com'], { cwd: checkout, env: gitEnvironment }); + await requireGit(['add', '--all', '--', '.'], { cwd: checkout, env: gitEnvironment }); + + const diffCode = await runGit(['diff', '--cached', '--quiet'], { cwd: checkout, env: gitEnvironment, quiet: true }); + if (diffCode === 0) { + process.stdout.write('GitHub Pages output is already up to date.\n'); + } else if (diffCode === 1) { + const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')); + await requireGit(['commit', '-m', `deploy: HolodoriCalc v${packageJson.version}`], { cwd: checkout, env: gitEnvironment }); + await requireGit(['push', 'origin', 'HEAD:main'], { cwd: checkout, env: gitEnvironment }); + } else { + throw new Error(`git diff failed with exit code ${diffCode}.`); + } +} finally { + const resolvedTemporaryRoot = path.resolve(temporaryRoot); + const resolvedSystemTemp = path.resolve(os.tmpdir()); + if (resolvedTemporaryRoot.startsWith(`${resolvedSystemTemp}${path.sep}`) && path.basename(resolvedTemporaryRoot).startsWith('holodori-pages-')) { + await rm(resolvedTemporaryRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/verify-pages-public-data.mjs b/scripts/verify-pages-public-data.mjs index 5c50b85..7273b07 100644 --- a/scripts/verify-pages-public-data.mjs +++ b/scripts/verify-pages-public-data.mjs @@ -36,21 +36,38 @@ if (mismatchedVersions.length > 0) { } 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}.`); +if (!Number.isFinite(approvedNotBefore)) { + throw new Error('approvedNotBefore must be a valid ISO-8601 date.'); } -const unreleasedCharts = charts.charts.filter((chart) => !Number.isFinite(chart.releaseTime) || chart.releaseTime > now); +const undatedCharts = charts.charts.filter((chart) => !Number.isFinite(chart.releaseTime)); +if (undatedCharts.length > 0) { + throw new Error(`Pages build contains ${undatedCharts.length} undated charts: ${undatedCharts.map((chart) => `${chart.songId}/${chart.difficulty}`).join(', ')}`); +} + +const undatedEvents = events.filter((event) => !Number.isFinite(Date.parse(event.startsAt))); +if (undatedEvents.length > 0) { + throw new Error(`Pages build contains undated events: ${undatedEvents.map((event) => event.id).join(', ')}`); +} + +const notReadyReasons = []; +if (now < approvedNotBefore) { + notReadyReasons.push(`approved master data cannot be published before ${policy.approvedNotBefore}`); +} + +const unreleasedCharts = charts.charts.filter((chart) => 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(', ')}`); + notReadyReasons.push(`${unreleasedCharts.length} charts are not released yet: ${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; -}); +const unreleasedEvents = events.filter((event) => Date.parse(event.startsAt) > now); if (unreleasedEvents.length > 0) { - throw new Error(`Pages build contains unreleased or undated events: ${unreleasedEvents.map((event) => event.id).join(', ')}`); + notReadyReasons.push(`events are not released yet: ${unreleasedEvents.map((event) => event.id).join(', ')}`); +} + +if (notReadyReasons.length > 0) { + process.stderr.write(`Pages public-data gate is not ready at ${new Date(now).toISOString()}: ${notReadyReasons.join('; ')}.\n`); + process.exit(78); } process.stdout.write(`Pages public-data gate passed at ${new Date(now).toISOString()}.\n`);