ci: schedule guarded GitHub Pages publishing
Publish GitHub Pages output / publish (push) Has been cancelled

This commit is contained in:
2026-08-31 13:01:29 +09:00
parent c0e8ab4bad
commit 51a4b0d9ca
5 changed files with 178 additions and 10 deletions
+55
View File
@@ -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
+15 -1
View File
@@ -1,6 +1,6 @@
# GitHub Pages 배포 # 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 방식으로 한 번 활성화해야 합니다. 최초 배포 전에 저장소의 Pages 소스를 GitHub Actions 방식으로 한 번 활성화해야 합니다.
개인 Git 서버의 예약 작업에서 실행할 때는 GitHub 저장소에만 쓰기 가능한 최소 권한 자격증명을 사용합니다. 원본 저장소를 GitHub 원격으로 추가하거나 원본 Git 이력을 Pages 저장소로 푸시하지 않습니다. 개인 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 저장소에 변경이 없으면 새 커밋을 만들지 않습니다.
+1
View File
@@ -13,6 +13,7 @@
"check:pages-data": "node scripts/verify-pages-public-data.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", "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", "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", "package:offline": "npm run build:offline && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-offline.ps1",
"preview": "vite preview --host 0.0.0.0", "preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+81
View File
@@ -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 });
}
}
+26 -9
View File
@@ -36,21 +36,38 @@ if (mismatchedVersions.length > 0) {
} }
const approvedNotBefore = Date.parse(policy.approvedNotBefore); const approvedNotBefore = Date.parse(policy.approvedNotBefore);
if (!Number.isFinite(approvedNotBefore) || now < approvedNotBefore) { if (!Number.isFinite(approvedNotBefore)) {
throw new Error(`The approved master data cannot be published before ${policy.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) { 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 unreleasedEvents = events.filter((event) => Date.parse(event.startsAt) > now);
const startsAt = Date.parse(event.startsAt);
return !Number.isFinite(startsAt) || startsAt > now;
});
if (unreleasedEvents.length > 0) { 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`); process.stdout.write(`Pages public-data gate passed at ${new Date(now).toISOString()}.\n`);