feat(deploy): support positional release inputs

This commit is contained in:
DeCentN2Madness committed 2026-11-07 14:02:35 -07:00
1 parent 2fca442922
commit a643383e79
3 files changed
+150 -25

No files matched your search

+68 -19
View File
@@ -83,9 +83,24 @@ export async function releaseManifest(dist, sourceCommit) {
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');
function validHost(host) {
return typeof host === 'string' && /^[A-Za-z0-9_.:@\-\[\]]+$/.test(host);
}
function usage() {
return [
'Usage:',
' npm run deploy -- --dry-run [SSH_HOST CIPHERTEXT | SSH_HOST SSH_IDENTITY CIPHERTEXT]',
' npm run deploy -- --confirm [SSH_HOST CIPHERTEXT | SSH_HOST SSH_IDENTITY CIPHERTEXT]',
'With no positional arguments, use AIA_DEPLOY_HOST, AIA_DEPLOY_SSH_IDENTITY and AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE.',
].join('\n');
}
export function sshTarget(host, identity, { allowSshConfig = false } = {}) {
if (!validHost(host)) fail('Deployment SSH host is invalid');
if (identity === undefined) {
if (!allowSshConfig) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path');
return ['ssh'];
}
if (!identity || !path.isAbsolute(identity)) fail('Deployment 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); }
@@ -111,6 +126,12 @@ async function syntheticPayload(directory) {
await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 });
return { file, payload, synthetic: true };
}
export async function portalInput(ciphertext, directory, env = process.env) {
if (!ciphertext) return syntheticPayload(directory);
const privateRoot = path.resolve(env.AIA_PORTAL_PRIVATE_ROOT || path.dirname(ciphertext));
const readiness = await assertProductionReadiness(privateRoot, ciphertext);
return { file: ciphertext, payload: readiness.payload, synthetic: false };
}
async function createSourceSnapshot(directory, sourceCommit) {
const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean);
const included = [], excluded = [];
@@ -149,19 +170,51 @@ async function executeBuild(buildEnv) {
run('npm', ['run', 'audit:blog'], { env: buildEnv });
run('npm', ['run', 'build'], { env: buildEnv });
}
function parseArgs(argv) {
export 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') };
const flags = argv.filter((arg) => arg.startsWith('--'));
const positional = argv.filter((arg) => !arg.startsWith('--'));
if (flags.some((arg) => !allowed.has(arg)) || new Set(flags).size !== flags.length) fail(usage());
if ((flags.includes('--dry-run') && flags.includes('--confirm')) || (!flags.includes('--dry-run') && !flags.includes('--confirm'))) fail(`${usage()}\nChoose exactly one of --dry-run or --confirm.`);
if (![0, 2, 3].includes(positional.length) || positional.some((arg) => !arg)) fail(`${usage()}\nProvide either zero, two, or three positional arguments.`);
return { dryRun: flags.includes('--dry-run'), confirm: flags.includes('--confirm'), positional };
}
async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
const [ssh, ...sshOptions] = sshTarget(host, identity);
export function deploymentInputs(options, env = process.env) {
const { positional } = options;
if (!positional.length) {
return {
host: env.AIA_DEPLOY_HOST,
identity: env.AIA_DEPLOY_SSH_IDENTITY || undefined,
ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE,
allowSshConfig: false,
source: 'environment',
};
}
const [host, maybeIdentity, maybeCiphertext] = positional;
const identity = positional.length === 3 ? maybeIdentity : undefined;
const ciphertext = positional.length === 3 ? maybeCiphertext : maybeIdentity;
if (!validHost(host)) fail('Positional SSH host is invalid');
if (identity !== undefined && !path.isAbsolute(identity)) fail('Positional SSH identity must be an absolute path');
if (!path.isAbsolute(ciphertext)) fail('Positional portal ciphertext path must be absolute');
return { host, identity, ciphertext, allowSshConfig: positional.length === 2, source: 'positional' };
}
function assertRemoteInputs(inputs) {
if (!validHost(inputs.host)) fail(inputs.source === 'environment' ? 'AIA_DEPLOY_HOST is required and must be valid for --confirm' : 'Positional SSH host is invalid');
if (!inputs.allowSshConfig && (!inputs.identity || !path.isAbsolute(inputs.identity))) fail('AIA_DEPLOY_SSH_IDENTITY is required and must be an absolute path for environment-based deployment');
}
function shellQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
export function rsyncTransport(sshArguments) {
return sshArguments.map(shellQuote).join(' ');
}
async function remoteDeploy({ host, identity, allowSshConfig, sha, source, dist, manifest }) {
const [ssh, ...sshOptions] = sshTarget(host, identity, { allowSshConfig });
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 transport = rsyncTransport([ssh, ...sshOptions]);
const transfer = (local, remote, remove) => {
const args = rsyncArguments(local, remote, { remove });
args.splice(-2, 0, '-e', transport);
@@ -175,18 +228,14 @@ async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
async function main() {
const options = parseArgs(process.argv.slice(2));
const inputs = deploymentInputs(options);
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');
const portal = await portalInput(inputs.ciphertext, temporary);
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE or a positional ciphertext path');
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
if (!options.dryRun) assertRemoteInputs(inputs);
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)));
@@ -198,7 +247,7 @@ async function main() {
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 });
await remoteDeploy({ ...inputs, 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 }); }
}