Files
ForgeFlow/scripts/acceptance.mjs
T
NuklearRabbit 4ad698c4eb Release ForgeFlow 0.8.1
Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
2026-07-26 00:42:17 +02:00

89 lines
6.1 KiB
JavaScript

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;
}