209 lines
15 KiB
JavaScript
209 lines
15 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));
|
|
// Build the sentinel in memory so this guardrail can itself be included in
|
|
// the server-validated source snapshot without looking like a PEM body.
|
|
if (bytes.includes(Buffer.from(['PRIVATE', ' KEY-----'].join('')))) 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', 'tools/counters.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: 'live', PUBLIC_AIA_API_BASE: 'https://api.azinstitute4autism.com', PUBLIC_COUNTERS_LIVE_APPROVED: 'true', 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 }, 'live');
|
|
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; });
|
|
}
|