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
+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`);