import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import crypto from 'node:crypto'; import process from 'node:process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const exec = promisify(execFile); const required = ['FORGEFLOW_GITEA_URL', 'FORGEFLOW_GITEA_TOKEN', 'FORGEFLOW_REPOSITORY', 'FORGEFLOW_LOCAL_PATH', 'FORGEFLOW_BRANCH', 'FORGEFLOW_STATUS_URL', 'FORGEFLOW_HEALTH_URL']; export function readAcceptanceConfig(env = process.env) { const missing = required.filter((name) => !String(env[name] || '').trim()); if (missing.length) throw new Error(`Missing acceptance environment variables: ${missing.join(', ')}`); const [owner, repo, extra] = env.FORGEFLOW_REPOSITORY.split('/'); if (!owner || !repo || extra) throw new Error('FORGEFLOW_REPOSITORY must use owner/repository.'); return { baseUrl: env.FORGEFLOW_GITEA_URL.replace(/\/+$/, ''), token: env.FORGEFLOW_GITEA_TOKEN, owner, repo, localPath: env.FORGEFLOW_LOCAL_PATH, branch: env.FORGEFLOW_BRANCH, workflow: env.FORGEFLOW_WORKFLOW || 'deploy.yml', rollbackWorkflow: env.FORGEFLOW_ROLLBACK_WORKFLOW || 'rollback.yml', environment: env.FORGEFLOW_ENVIRONMENT || 'staging', statusUrl: env.FORGEFLOW_STATUS_URL, healthUrl: env.FORGEFLOW_HEALTH_URL }; } async function git(config, args) { return (await exec('git', args, { cwd: config.localPath, encoding: 'utf8' })).stdout.trim(); } async function api(config, pathname, options = {}) { const response = await fetch(`${config.baseUrl}/api/v1${pathname}`, { method: options.method || 'GET', headers: { Authorization: `token ${config.token}`, Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined, signal: AbortSignal.timeout(30_000) }); const text = await response.text(); if (!response.ok) throw new Error(`Gitea ${response.status}: ${text.slice(0, 500)}`); return text ? JSON.parse(text) : null; } async function publicJson(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); return response.json(); } async function health(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); return { ok: response.ok, status: response.status }; } export async function inspectAcceptanceEnvironment(config) { const [head, branch, porcelain, upstream, repository, remoteBranch, workflow, server, healthResult] = await Promise.all([ git(config, ['rev-parse', 'HEAD']), git(config, ['branch', '--show-current']), git(config, ['status', '--porcelain']), git(config, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).catch(() => ''), api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}`), api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/branches/${encodeURIComponent(config.branch)}`), api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/contents/.gitea/workflows/${encodeURIComponent(config.workflow)}?ref=${encodeURIComponent(config.branch)}`), publicJson(config.statusUrl), health(config.healthUrl) ]); const checks = [ { id: 'clean', ok: !porcelain, detail: porcelain ? 'Working tree has changes' : 'Working tree clean' }, { id: 'branch', ok: branch === config.branch, detail: `Local ${branch}; expected ${config.branch}` }, { id: 'upstream', ok: Boolean(upstream), detail: upstream || 'No upstream' }, { id: 'repository', ok: repository.full_name?.toLowerCase() === `${config.owner}/${config.repo}`.toLowerCase(), detail: repository.full_name }, { id: 'remote-sha', ok: remoteBranch.commit?.id === head, detail: `local ${head.slice(0, 7)}; remote ${(remoteBranch.commit?.id || '').slice(0, 7)}` }, { id: 'workflow', ok: workflow.type === 'file', detail: config.workflow }, { id: 'status', ok: Boolean(server && typeof server === 'object'), detail: server?.liveSha || 'No live SHA' }, { id: 'health', ok: healthResult.ok, detail: `HTTP ${healthResult.status}` } ]; return { generatedAt: new Date().toISOString(), head, server, checks, ready: checks.every((check) => check.ok) }; } async function waitForSha(config, sha, requestId, timeoutMs = 15 * 60_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const state = await publicJson(config.statusUrl); if (state.requestId === requestId && state.liveSha === sha) { const probe = await health(config.healthUrl); if (probe.ok) return state; } await new Promise((resolve) => setTimeout(resolve, 10_000)); } throw new Error(`Timed out waiting for exact live SHA ${sha}.`); } export async function executeAcceptanceDeployment(config, sha, workflow = config.workflow, inputName = 'commit_sha') { const requestId = crypto.randomUUID(); await api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: { ref: config.branch, inputs: { environment: config.environment, [inputName]: sha, request_id: requestId } } }); return { requestId, state: await waitForSha(config, sha, requestId) }; } if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) { const config = readAcceptanceConfig(); const report = await inspectAcceptanceEnvironment(config); if (process.argv.includes('--execute-deployment')) { if (!report.ready) throw new Error('Read-only acceptance checks must pass before deployment execution.'); report.deployment = await executeAcceptanceDeployment(config, report.head); } if (process.argv.includes('--execute-rollback')) { const target = report.server?.previousSha; if (!target) throw new Error('Status endpoint does not report a previousSha for rollback acceptance.'); report.rollback = await executeAcceptanceDeployment(config, target, config.rollbackWorkflow, 'target_sha'); } console.log(JSON.stringify(report, null, 2)); if (!report.ready) process.exitCode = 1; }