Files
quickly-conf-sshd/scripts/notify-deployment.mjs
T

93 lines
4.9 KiB
JavaScript

// Optional JSON webhook for a separately configured email delivery service.
// This sends deployment configuration, not observed server/fleet state.
import {readFile} from 'node:fs/promises';
import {resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
export function notificationEnabled(value) {
if (value === undefined || value === '' || value === 'false') return false;
if (value === 'true') return true;
throw Error('ENABLE_MAIL_NOTIFICATION must be unset, false, or exactly true.');
}
function httpsUrl(value, name) {
let url;
try { url = new URL(value); } catch { throw Error(`${name} must be an HTTPS URL.`); }
if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw Error(`${name} must be HTTPS without URL credentials or fragments.`);
return url.href;
}
export function deploymentPayload(config, env) {
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
const status = env.DEPLOYMENT_STATUS;
if (!['success','failure','cancelled'].includes(status)) throw Error('DEPLOYMENT_STATUS must be success, failure, or cancelled.');
const phase = env.DEPLOYMENT_PHASE;
if (!['deployment','remote-verification','complete'].includes(phase)) throw Error('DEPLOYMENT_PHASE must identify the attempted deployment phase.');
if (!/^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '')) throw Error('GITHUB_SHA must be an exact 40-hex commit SHA.');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.GITHUB_REPOSITORY || '') || !/^\d+$/.test(env.GITHUB_RUN_ID || '')) throw Error('A valid GitHub repository and run ID are required.');
const mode = config.keySource?.type ?? 'git';
if (!['git','gist'].includes(mode)) throw Error('Unsupported deployment key source mode.');
const listId = config.signedRelease?.listId ?? null;
if (listId !== null && (typeof listId !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(listId))) throw Error('Invalid deployment list identity.');
const fallbackSources = config.fallbackSources ?? [];
if (!Array.isArray(fallbackSources) || fallbackSources.length > 5) throw Error('Invalid deployment fallback sources.');
const to = env.MAIL_NOTIFICATION_TO || null;
if (to !== null && (to.length > 254 || !/^[^\s@,<>]+@[^\s@,<>]+\.[^\s@,<>]+$/.test(to))) throw Error('MAIL_NOTIFICATION_TO must be one email address.');
return {
schema:1,
event:'worker-deployment',
status,
phase,
repository:env.GITHUB_REPOSITORY,
revision:env.GITHUB_SHA,
workflowRunUrl:`https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
to,
configuration:{
baseUrl:httpsUrl(config.baseUrl, 'Deployment baseUrl'),
mode,
listId,
signedRelease:Boolean(config.signedRelease),
fallbackSources:fallbackSources.map(value => httpsUrl(value,'Deployment fallback source')),
},
configurationScope:'deployment configuration only; not measured server state',
};
}
export async function notifyDeployment({env = process.env, config, fetchImpl = globalThis.fetch} = {}) {
if (!notificationEnabled(env.ENABLE_MAIL_NOTIFICATION)) return {sent:false,reason:'disabled'};
if (env.GITHUB_ACTIONS !== 'true' || env.CI !== 'true') throw Error('Deployment notification sending is allowed only in GitHub Actions CI.');
if (!env.MAIL_NOTIFICATION_WEBHOOK) throw Error('MAIL_NOTIFICATION_WEBHOOK is required when notifications are enabled.');
const endpoint = httpsUrl(env.MAIL_NOTIFICATION_WEBHOOK,'Notification webhook');
const payload = deploymentPayload(config,env);
let response;
try {
response = await fetchImpl(endpoint, {
method:'POST',
headers:{'content-type':'application/json'},
body:JSON.stringify(payload),
redirect:'error',
signal:AbortSignal.timeout(10_000),
});
} catch {
// Provider errors may contain secret URL tokens. Do not print them or retry
// automatically: a timed-out request may already have sent the email.
throw Error('Deployment notification request failed; check the webhook service.');
}
if (!response.ok) throw Error(`Deployment notification was rejected (HTTP ${response.status}).`);
// Do not log response bodies, the recipient, or the secret webhook URL.
return {sent:true};
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.argv.length !== 2) throw Error('Usage: node scripts/notify-deployment.mjs');
let config;
if (notificationEnabled(process.env.ENABLE_MAIL_NOTIFICATION)) {
try { config = JSON.parse(await readFile(new URL('../deployment.json',import.meta.url),'utf8')); }
catch { throw Error('Cannot read deployment notification configuration.'); }
}
const result = await notifyDeployment({config});
console.log(result.sent ? 'Deployment notification webhook accepted the request.' : 'Deployment notification disabled.');
} catch (error) { console.error(error.message); process.exitCode = 1; }
}