/* RFC-1091: github-pages adapter — deploys static-site Sternsystemen to GitHub Pages using the gh-pages npm package. Implements the full DeploymentAdapter interface (propagate, rollback, health, getLimits). Do not implement GitHub Actions-based deployment — uses gh-pages npm package from local machine. Do not add custom domain or DNS management for GitHub Pages. Do not log, echo, or serialize secret values or resolved secrets-file contents. RFC-1091: initial github-pages adapter with injectable CommandRunner, GH_TOKEN from filterEnv(process.env), health checks via shared health-helpers. RFC-1091: source GH_TOKEN from secretsFilePath (workpiece .env) like cloudflare-workers adapter. REVIEW-CODE-2026-10-01-01: bootSmokeCapable() → false — static adapter emits no worker bundle, workpiece wrangler.jsonc is vestigial. RFC-1188: Web Bot Auth outbound identity: wba-sign primitives, directory emit+sweep, AGS-WBA-01, leitstand signed fetches */ import path from "node:path"; import type { PropagationResult, HealthCheck } from "@warpgogol/werkstatt-engine/schemas"; import { hashHtml } from "@warpgogol/werkstatt-engine/fingerprint"; import type { CommandRunner, DeploymentAdapter, PropagateInput, RollbackInput, RollbackResult, HealthInput, } from "../adapter.ts"; import { filterEnv, sourceDotenv, readBehaviorSnapshot, verifyRedirectRoute, selectProbeRoutes, fetchWithRetry, createDefaultCommandRunner, } from "./health-helpers.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export interface GitHubPagesAdapterConfig { /** GitHub repository slug: "owner/repo" or just "repo" (uses GH_TOKEN owner) */ repo: string; /** Branch to push dist/ to (default: "gh-pages") */ branch?: string; /** GitHub token for git push authentication (from GH_TOKEN env) */ token: string; /** Optional: message for the gh-pages commit (default: "deploy ") */ message?: string; } function resolveRepoSlug(workerName: string, tokenOwner?: string): string { if (workerName.includes("/")) return workerName; if (tokenOwner) return `${tokenOwner}/${workerName}`; return workerName; } function extractTokenOwner(_token: string): string | undefined { // GitHub tokens don't encode the owner. We can't resolve it from the token alone. // The operator must use "owner/repo" format in workerName if the token owner // can't be inferred. Return undefined — the caller handles the fallback. return undefined; } export function createGitHubPagesAdapter(exec?: CommandRunner): DeploymentAdapter { const runner = exec ?? createDefaultCommandRunner(); return { name: "github-pages", async propagate( input: PropagateInput, io: WorkspaceIO = createDefaultIO().io, ): Promise { const now = new Date().toISOString(); const secretsEnv = input.secretsFilePath ? await sourceDotenv(input.secretsFilePath) : {}; const env = { ...filterEnv(process.env), ...secretsEnv }; const token = env.GH_TOKEN; if (!token) { return { systemId: input.systemId, releaseId: input.releaseId, state: "failed", deploymentUrl: input.url, startedAt: now, completedAt: new Date().toISOString(), healthChecks: [], }; } const repo = resolveRepoSlug(input.workerName, extractTokenOwner(token)); const branch = "gh-pages"; const message = `deploy ${input.releaseId}`; const distDir = path.join(input.distPath, "client"); const distExists = await io.exists(distDir); if (!distExists) { return { systemId: input.systemId, releaseId: input.releaseId, state: "failed", deploymentUrl: input.url, startedAt: now, completedAt: new Date().toISOString(), healthChecks: [], }; } // GitHub Pages runs Jekyll on legacy branch deploys, which drops // underscore- and dot-prefixed paths (_astro/, .well-known/, _headers). // An empty .nojekyll marker bypasses Jekyll entirely. await io.writeFile(path.join(distDir, ".nojekyll"), ""); const repoUrl = `https://x-access-token:${token}@github.com/${repo}.git`; const ghPagesArgs = [ "--yes", "gh-pages", "-d", "client", "--dotfiles", "-b", branch, "-r", repoUrl, "-m", message, ]; const runnerEnv: Record = { ...env, GH_TOKEN: token }; if (input.nodeModulesBinPath) { runnerEnv.PATH = `${input.nodeModulesBinPath}:${process.env.PATH ?? ""}`; } const result = await runner("npx", ghPagesArgs, { cwd: input.distPath, env: runnerEnv, }); if (result.exitCode !== 0) { console.error( `[github-pages] gh-pages publish failed (exit ${result.exitCode}): ${result.stderr.trim().slice(-300)}`, ); return { systemId: input.systemId, releaseId: input.releaseId, state: "failed", deploymentUrl: input.url, startedAt: now, completedAt: new Date().toISOString(), healthChecks: [], }; } return { systemId: input.systemId, releaseId: input.releaseId, state: "succeeded", deploymentUrl: input.url, startedAt: now, completedAt: new Date().toISOString(), healthChecks: [], }; }, async rollback(input: RollbackInput): Promise { const now = new Date().toISOString(); return { systemId: input.systemId, channel: input.channel, state: "succeeded", workerName: input.workerName, startedAt: now, completedAt: new Date().toISOString(), stdout: "", stderr: "GitHub Pages does not support version-level rollback. Re-deploy a previous release through the standard pipeline.", }; }, getLimits() { return { maxTotalSize: 1 * 1024 * 1024 * 1024, maxFileSize: 100 * 1024 * 1024, }; }, purgeCapable() { return false; }, bootSmokeCapable() { return false; }, async health( input: HealthInput, ): Promise<{ state: "healthy" | "unhealthy" | "unknown"; checks: HealthCheck[] }> { const maxProbes = 10; const maxAttempts = 5; const snapshot = await readBehaviorSnapshot(input.workspaceRoot, input.releaseId); const routes = snapshot?.routes ?? []; const probeRoutes = selectProbeRoutes(routes, maxProbes); if (probeRoutes.length === 0) { return { state: "unknown", checks: [ { name: "probe-selection", url: input.deploymentUrl, status: 0, passed: false, detail: "No routes available in behavior snapshot for probing", }, ], }; } const checks: HealthCheck[] = []; let allPassed = true; let anyNetworkFailure = false; let anyContentMismatch = false; for (const route of probeRoutes) { const url = `${input.deploymentUrl}${route.path === "/" ? "" : route.path}`; if (route.contentHash === null) { const response = await fetchWithRetry( url, maxAttempts, "manual", input.authHeaders ?? {}, input.agentFetch, ); if (response === null) { anyNetworkFailure = true; allPassed = false; checks.push({ name: `probe:${route.path}`, url, status: 0, passed: false, detail: "Network failure after retries", }); continue; } const location = response.headers.get("location") ?? ""; const result = verifyRedirectRoute(response.status, location, route); if (!result.passed) allPassed = false; if (response.status !== 307 && response.status !== 308) anyContentMismatch = true; checks.push({ name: `probe:${route.path}`, url, status: response.status, passed: result.passed, detail: result.detail, }); continue; } const response = await fetchWithRetry(url, maxAttempts, "follow", input.authHeaders ?? {}, input.agentFetch); if (response === null) { anyNetworkFailure = true; allPassed = false; checks.push({ name: `probe:${route.path}`, url, status: 0, passed: false, detail: "Network failure after retries", expectedHash: route.contentHash, }); continue; } if (!response.ok) { allPassed = false; checks.push({ name: `probe:${route.path}`, url, status: response.status, passed: false, detail: `HTTP ${response.status}`, expectedHash: route.contentHash, }); continue; } if (route.contentHash) { const actualHash = hashHtml(response.body); const hashMatch = actualHash === route.contentHash; if (!hashMatch) { allPassed = false; anyContentMismatch = true; } checks.push({ name: `probe:${route.path}`, url, status: response.status, passed: hashMatch, detail: hashMatch ? "Content hash matches" : "Content hash mismatch", expectedHash: route.contentHash, actualHash, }); } else { checks.push({ name: `probe:${route.path}`, url, status: response.status, passed: true, detail: "HTTP OK, no content hash in snapshot", }); } } const state: "healthy" | "unhealthy" | "unknown" = anyContentMismatch ? "unhealthy" : anyNetworkFailure ? "unknown" : allPassed ? "healthy" : "unhealthy"; return { state, checks }; }, }; }