Files
aia-website/www/tools/deploy.mjs
T

207 lines
14 KiB
JavaScript

/**
* AIA static-site publisher. This tool prepares a reviewed release locally and
* uses only the Contabo release-manager interface for remote verification and
* atomic promotion. It never writes the active www/dist symlink directly.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
import { verifyArtifact } from './phase10-artifact-verify.mjs';
import { assertProductionReadiness } from './portal-production.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..');
const projectRoot = '/srv/www/azinstitute4autism.com';
const fullSha = /^[0-9a-f]{40}$/;
const publicExtensions = new Set(['.html','.css','.js','.json','.xml','.txt','.svg','.png','.jpg','.jpeg','.webp','.gif','.ico','.avif','.woff','.woff2','.ttf','.otf','.pdf','.mp4','.webm','.mp3']);
const sourceRejectedParts = new Set(['.git','.github','node_modules','private','secrets','dist','.astro']);
const sourceRejectedNames = new Set(['.netrc','.npmrc','.pypirc','.yarnrc','.yarnrc.yml','auth.json','credentials','id_ed25519','id_rsa']);
const stableCompare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
function fail(message) { throw new Error(message); }
function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
function run(command, args, { cwd = toolRoot, env = process.env, quiet = false } = {}) {
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8' });
if (result.error) fail(`${command} is unavailable: ${result.error.message}`);
if (!quiet && result.stdout) process.stdout.write(result.stdout);
if (result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
return result.stdout.trim();
}
function commandExists(command) { return spawnSync('which', [command], { stdio: 'ignore' }).status === 0; }
function safeRelative(name) {
const normalized = name.replaceAll(path.sep, '/');
if (!normalized || normalized.startsWith('/') || normalized.split('/').some((part) => !part || part === '.' || part === '..' || /[\x00-\x1f\\";{}]/.test(part))) fail(`Unsafe inventory path: ${name}`);
return normalized;
}
function publicPath(name) {
const safe = safeRelative(name);
if ((safe.split('/').some((part) => part.startsWith('.')) && safe !== 'assets/media/.gitkeep') || (!publicExtensions.has(path.extname(safe).toLowerCase()) && safe !== 'assets/media/.gitkeep') || safe.split('/').some((part) => ['secrets','private','node_modules','.git'].includes(part.toLowerCase()))) fail(`Unexpected public artifact path: ${safe}`);
return safe;
}
function sourcePath(name) {
const safe = safeRelative(name);
const parts = safe.split('/');
if (safe === 'merge-plan.md' || ['assets','releases','secure-resources','staging'].includes(parts[0]) || parts.some((part) => sourceRejectedParts.has(part.toLowerCase())) || (parts[0] === 'www' && parts[1] === 'dist') || safe === '.aia-source-state.json' || safe === '.aia-source-pending.json' || path.basename(safe) === '.env' || (path.basename(safe).startsWith('.env.') && path.basename(safe) !== '.env.example') || sourceRejectedNames.has(path.basename(safe).toLowerCase()) || ['.key','.pem','.p12','.pfx'].includes(path.extname(safe).toLowerCase())) fail(`Tracked path is not deployable source: ${safe}`);
return safe;
}
function treeSha256(files) {
const digest = createHash('sha256');
for (const [name, meta] of Object.entries(files).sort(([a], [b]) => stableCompare(a, b))) digest.update(`${name}\0${meta.sha256}\n`);
return digest.digest('hex');
}
export async function releaseManifest(dist, sourceCommit) {
if (!fullSha.test(sourceCommit)) fail('A full source Git SHA is required');
const files = {}, directories = [];
async function walk(relative = '') {
const current = path.join(dist, relative);
for (const entry of (await fs.readdir(current, { withFileTypes: true })).sort((a, b) => stableCompare(a.name, b.name))) {
const child = relative ? `${relative}/${entry.name}` : entry.name;
const stat = await fs.lstat(path.join(dist, child));
if (stat.isSymbolicLink() || !(stat.isDirectory() || stat.isFile()) || (stat.isFile() && stat.nlink !== 1)) fail(`Unsafe artifact entry: ${child}`);
if (stat.isDirectory()) {
const safe = safeRelative(child);
if (safe.split('/').some((part) => ['private','hubfs','staff-resources','management-resources','secrets'].includes(part.toLowerCase()))) fail(`Unexpected private artifact directory: ${safe}`);
directories.push(safe); await walk(child); continue;
}
publicPath(child);
const bytes = await fs.readFile(path.join(dist, child));
if (bytes.includes(Buffer.from('PRIVATE KEY-----'))) fail(`Private-key material in artifact: ${child}`);
files[child] = { sha256: sha256(bytes), size: bytes.length };
}
}
await walk();
if (!files['index.html']) fail('Artifact is missing index.html');
const sortedFiles = Object.fromEntries(Object.entries(files).sort(([a], [b]) => stableCompare(a, b)));
return { schema: 1, sourceCommit, treeSha256: treeSha256(sortedFiles), files: sortedFiles, directories: directories.sort(stableCompare), immutableAssets: Object.keys(sortedFiles).filter((name) => /^_astro\/[^/]+\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(name)).sort(stableCompare) };
}
export function rsyncArguments(source, destination, { remove = false } = {}) {
return ['-rlt', ...(remove ? ['--delete'] : []), '--links', '--chown=root:root', '--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=', source.endsWith('/') ? source : `${source}/`, destination];
}
export function sshTarget(host, identity) {
if (!/^[A-Za-z0-9_.:@\-\[\]]+$/.test(host ?? '')) fail('AIA_DEPLOY_HOST is invalid');
if (!identity || !path.isAbsolute(identity)) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path');
return ['ssh', '-i', identity, '-o', 'IdentitiesOnly=yes'];
}
function git(args, options) { return run('git', ['-C', repositoryRoot, ...args], options); }
function assertPreflight({ requirePrimary = true } = {}) {
if (git(['rev-parse', '--is-inside-work-tree'], { quiet: true }) !== 'true') fail('Run deploy from the AIA repository worktree');
if (requirePrimary && git(['branch', '--show-current'], { quiet: true }) !== 'faithful-astro-migration') fail('Deploy only from faithful-astro-migration after its reviewed commit is checked out');
const sha = git(['rev-parse', 'HEAD'], { quiet: true });
if (!fullSha.test(sha)) fail('Unable to determine full Git commit SHA');
if (git(['status', '--porcelain=v1', '--untracked-files=all'], { quiet: true })) fail('Deploy requires a clean tracked and untracked worktree');
const gitDir = git(['rev-parse', '--git-dir'], { quiet: true });
for (const state of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']) {
if (spawnSync('test', ['-e', path.join(repositoryRoot, gitDir, state)]).status === 0) fail(`Deploy refused during Git ${state}`);
}
for (const command of ['git', 'tar', 'ssh', 'rsync']) if (!commandExists(command)) fail(`Required deployment tool is unavailable: ${command}`);
return sha;
}
async function syntheticPayload(directory) {
const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('synthetic-staff', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'),
await encryptPortalStream('synthetic-management', '<p>Synthetic portal fixture.</p>', 'synthetic-management-code'),
] };
const file = path.join(directory, 'synthetic-portal-ciphertext.json');
await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 });
return { file, payload, synthetic: true };
}
async function createSourceSnapshot(directory, sourceCommit) {
const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean);
const included = [], excluded = [];
for (const name of tracked) {
try { sourcePath(name); included.push(name); }
catch (error) {
if (name === 'merge-plan.md' || name === '.github' || name.startsWith('.github/')) excluded.push(name);
else fail(error.message);
}
}
if (!included.includes('www/package.json') || !included.some((name) => name.startsWith('www/src/'))) fail('Sanitized source snapshot is incomplete');
const archive = path.join(directory, 'source.tar');
const result = spawnSync('git', ['-C', repositoryRoot, 'archive', '--format=tar', sourceCommit, '--', ...included], { maxBuffer: 512 * 1024 * 1024 });
if (result.status !== 0) fail('git archive failed while producing the source snapshot');
await fs.writeFile(archive, result.stdout, { mode: 0o600 });
const snapshot = path.join(directory, `source-${sourceCommit}`);
await fs.mkdir(snapshot, { mode: 0o700 });
run('tar', ['-xf', archive, '-C', snapshot]);
await fs.rm(archive);
return { snapshot, included: included.length, excluded };
}
function validationEnvironment() {
const env = { ...process.env };
// Regression fixtures build their own publication modes. Do not let the
// production-only portal payload gate leak into those isolated builds.
for (const name of ['AIA_PORTAL_LOCAL_PREVIEW_APPROVED', 'AIA_PORTAL_PRODUCTION_APPROVED', 'AIA_PORTAL_CIPHERTEXT_FILE', 'AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE']) delete env[name];
return env;
}
async function executeBuild(buildEnv) {
const testEnv = validationEnvironment();
run('npm', ['run', 'check'], { env: testEnv });
run('node', ['--experimental-strip-types', '--test', 'tools/jotform-form-a.test.mjs', 'tools/service-portal.test.mjs', 'tools/phase10-artifact.test.mjs'], { env: testEnv });
run('npm', ['run', 'test:publication'], { env: testEnv });
run('npm', ['run', 'audit:links'], { env: testEnv });
run('npm', ['run', 'audit:images'], { env: testEnv });
run('npm', ['run', 'audit:blog'], { env: buildEnv });
run('npm', ['run', 'build'], { env: buildEnv });
}
function parseArgs(argv) {
const allowed = new Set(['--dry-run', '--confirm']);
if (argv.some((arg) => !allowed.has(arg))) fail('Usage: npm run deploy -- --dry-run | npm run deploy -- --confirm');
if (argv.includes('--dry-run') && argv.includes('--confirm')) fail('Choose --dry-run or --confirm, not both');
return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') };
}
async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
const [ssh, ...sshOptions] = sshTarget(host, identity);
const sourceStage = `${projectRoot}/staging/source-${sha}`;
const distStage = `${projectRoot}/staging/dist-${sha}`;
const remoteManifest = `/root/aia-site-${sha}.manifest.json`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${sourceStage} ${distStage}`]);
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
const transfer = (local, remote, remove) => {
const args = rsyncArguments(local, remote, { remove });
args.splice(-2, 0, '-e', transport);
run('rsync', args);
};
transfer(source, `${host}:${sourceStage}`, true);
transfer(dist, `${host}:${distStage}`, true);
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, manifest, `${host}:${remoteManifest}`]);
run(ssh, [...sshOptions, host, `aia-website-release verify ${distStage} ${remoteManifest} && aia-website-release sync-source ${sourceStage} ${sha} && aia-website-release promote ${distStage} ${remoteManifest} && readlink ${projectRoot}/www/dist`]);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sha = assertPreflight({ requirePrimary: !options.dryRun });
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
try {
const configured = process.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE;
let portal;
if (configured) {
const privateRoot = path.resolve(process.env.AIA_PORTAL_PRIVATE_ROOT || path.dirname(configured));
const readiness = await assertProductionReadiness(privateRoot, configured);
portal = { file: configured, payload: readiness.payload, synthetic: false };
} else portal = await syntheticPayload(temporary);
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'off', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' };
await executeBuild(env);
const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload)));
const artifact = await verifyArtifact(path.join(toolRoot, 'dist'), 'indexing', 'live', { mode: portal.synthetic ? 'synthetic' : 'production', sha256: portal.synthetic ? undefined : portalSha256 });
if (!artifact.passed) fail(`Artifact verification failed: ${artifact.errors.join('; ')}`);
const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha);
const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`);
await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
const snapshot = await createSourceSnapshot(temporary, sha);
const summary = { sourceCommit: sha, treeSha256: manifest.treeSha256, files: Object.keys(manifest.files).length, directories: manifest.directories.length, sourceFiles: snapshot.included, intentionallyExcludedSourceFiles: snapshot.excluded, portal: portal.synthetic ? 'synthetic-dry-run-only' : 'approved-production-ciphertext', destination: projectRoot };
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, ...summary }, null, 2)); return; }
await remoteDeploy({ host: process.env.AIA_DEPLOY_HOST, identity: process.env.AIA_DEPLOY_SSH_IDENTITY, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile });
console.log(JSON.stringify({ promoted: true, ...summary }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => { console.error(`Deployment stopped: ${error.message}`); process.exitCode = 1; });
}