Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
89 lines
6.1 KiB
JavaScript
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;
|
|
}
|