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

+36 -5
View File
@@ -7,11 +7,14 @@ It does not run `nixos-rebuild`, reload nginx, change DNS, or write directly to
## Required local tools and private inputs
- Node.js 22+, Git, tar, SSH and rsync.
- A normal SSH identity accepted by the existing root receiver:
`AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`.
- `AIA_DEPLOY_HOST=administrator@host` (or the approved equivalent).
- For a real public release, an owner-controlled, regular encrypted payload:
- Either an OpenSSH host alias already configured locally, or a normal SSH
identity accepted by the existing root receiver.
- For environment-only invocations: `AIA_DEPLOY_HOST=administrator@host`,
`AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`, and
`AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json`.
- For positional invocations, use either `SSH_HOST CIPHERTEXT` (OpenSSH resolves
the host alias and identity) or `SSH_HOST SSH_IDENTITY CIPHERTEXT` (the CLI
supplies `-i` and `IdentitiesOnly=yes`).
The payload remains outside Git and the source snapshot. It is browser ciphertext
only; neither access code nor decrypted portal HTML enters an Astro environment
@@ -23,6 +26,15 @@ From `www/`:
```sh
npm run deploy -- --dry-run
# Use a configured OpenSSH host alias and a private ciphertext path.
npm run deploy -- --dry-run aiar \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
# Supply an explicit SSH identity; paths may contain spaces.
npm run deploy -- --dry-run 'root@66.94.121.180' \
"$HOME/.ssh/AIAMarketing" \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
```
Dry-run performs the local production build, tests, artifact verification,
@@ -37,8 +49,27 @@ AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE="$PWD/private/employee-portal/approved-pro
npm run deploy -- --confirm
```
The equivalent confirmed positional commands are:
```sh
npm run deploy -- --confirm aiar \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
npm run deploy -- --confirm 'root@66.94.121.180' \
"$HOME/.ssh/AIAMarketing" \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
```
When positional values are present, they take precedence over all three
deployment environment variables. Two positional values deliberately omit
`-i`, allowing the local OpenSSH configuration to select the identity. Dry-run
never opens SSH or rsync, even when host and identity arguments are provided.
Invalid modes, mixed confirmation flags, incomplete positional sets, unsafe
hosts, relative positional paths, and missing ciphertext files fail before a
release can be promoted.
The confirmed command builds with `PUBLIC_ALLOW_INDEXING=true`, Form A live and
counters off, then uploads only fresh commit-specific `staging/source-<sha>` and
the approved live counters configuration, then uploads only fresh commit-specific `staging/source-<sha>` and
`staging/dist-<sha>` directories. It runs, in order:
```text
+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 }); }
}
+46 -1
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs';
import { deploymentInputs, parseArgs, portalInput, releaseManifest, rsyncArguments, rsyncTransport, sshTarget } from './deploy.mjs';
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
@@ -42,3 +42,48 @@ test('deployment transfer uses the Contabo ownership and mode interface', () =>
assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']);
assert.throws(() => sshTarget('host;rm -rf /', '/absolute/key'), /invalid/);
});
test('deployment accepts a positional OpenSSH alias and ciphertext ahead of environment settings', () => {
const options = parseArgs(['--dry-run', 'aiar', '/private files/portal ciphertext.json']);
const inputs = deploymentInputs(options, {
AIA_DEPLOY_HOST: 'wrong.example',
AIA_DEPLOY_SSH_IDENTITY: '/wrong/key',
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/wrong/payload.json',
});
assert.deepEqual(inputs, {
host: 'aiar', identity: undefined, ciphertext: '/private files/portal ciphertext.json', allowSshConfig: true, source: 'positional',
});
assert.deepEqual(sshTarget(inputs.host, inputs.identity, { allowSshConfig: inputs.allowSshConfig }), ['ssh']);
assert.equal(rsyncTransport(['ssh']), "'ssh'");
});
test('deployment accepts positional explicit identities without shell-splitting paths', () => {
const options = parseArgs(['--confirm', 'root@66.94.121.180', '/keys/AIA Marketing', '/private files/portal ciphertext.json']);
const inputs = deploymentInputs(options, {});
assert.equal(inputs.allowSshConfig, false);
assert.equal(inputs.identity, '/keys/AIA Marketing');
assert.deepEqual(sshTarget(inputs.host, inputs.identity), ['ssh', '-i', '/keys/AIA Marketing', '-o', 'IdentitiesOnly=yes']);
assert.equal(rsyncTransport(sshTarget(inputs.host, inputs.identity)), "'ssh' '-i' '/keys/AIA Marketing' '-o' 'IdentitiesOnly=yes'");
});
test('deployment retains environment-only compatibility and rejects malformed positional input', () => {
const env = {
AIA_DEPLOY_HOST: 'root@host.example',
AIA_DEPLOY_SSH_IDENTITY: '/absolute/key',
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/private/payload.json',
};
assert.deepEqual(deploymentInputs(parseArgs(['--confirm']), env), {
host: env.AIA_DEPLOY_HOST, identity: env.AIA_DEPLOY_SSH_IDENTITY, ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE, allowSshConfig: false, source: 'environment',
});
assert.throws(() => parseArgs(['--dry-run', 'aiar']), /zero, two, or three positional/);
assert.throws(() => parseArgs(['--dry-run', '--confirm']), /Choose exactly one/);
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative.json']), {}), /must be absolute/);
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative-key', '/private/payload.json']), {}), /must be an absolute path/);
});
test('a supplied ciphertext path must exist and be safe before a deployment build starts', async () => {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-missing-ciphertext-'));
try {
await assert.rejects(portalInput(path.join(temporary, 'missing.json'), temporary), /Portal ciphertext is unavailable or unsafe/);
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
});