hypertwist/website/scripts/run-vps-same-origin-live-deploy.mjs
2026-06-22 08:07:39 +00:00

293 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
buildRuntimeReadinessReport,
fetchLiveAuthHealth,
fetchLiveReleaseManifest,
fetchLiveWebsiteShell,
formatRuntimeReadinessReport,
parseEnvFile,
} from './runtime-readiness-lib.mjs'
import { writeSameOriginBundle } from './render-same-origin-bundle-lib.mjs'
import {
buildDefaultLiveDeployBundleDir,
buildRemoteLiveDeployScript,
buildSshArgs,
buildStageBundleRemoteCommand,
buildStageCheckoutRemoteCommand,
extractLiveDeploySummary,
loadLiveDeployManifest,
parseLiveDeployArgs,
resolveLiveDeployOptions,
sanitizeLiveDeployManifest,
} from './run-vps-same-origin-live-deploy-lib.mjs'
const currentFile = fileURLToPath(import.meta.url)
const websiteRoot = path.resolve(path.dirname(currentFile), '..')
const repoRoot = path.resolve(websiteRoot, '..')
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
...options,
})
if (result.error) {
throw result.error
}
return result
}
function buildArchiveBuffer(repoRootPath, archiveSource) {
if (archiveSource === 'worktree') {
return execFileSync('tar', [
'--exclude=website/node_modules',
'--exclude=website/dist',
'--exclude=website/coverage',
'--exclude=website/server/node_modules',
'--exclude=website/server/dist',
'-cf',
'-',
'website',
], {
cwd: repoRootPath,
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
}
return execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], {
cwd: repoRootPath,
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
}
function buildRenderedBundleArchive(renderedDir) {
return execFileSync('tar', ['-cf', '-', '-C', renderedDir, '.'], {
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function waitForLiveAuthHealth(baseUrl, waitSeconds) {
let lastError = null
for (let attempt = 1; attempt <= waitSeconds; attempt += 1) {
try {
return await fetchLiveAuthHealth(baseUrl)
} catch (error) {
lastError = error
if (attempt < waitSeconds) {
await sleep(1000)
}
}
}
throw lastError || new Error(`Live auth health did not become ready within ${waitSeconds} seconds.`)
}
async function main() {
const rawArgs = parseLiveDeployArgs(process.argv.slice(2))
const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim()
const { path: manifestPath, manifest } = loadLiveDeployManifest(rawArgs.manifest)
const options = resolveLiveDeployOptions({
manifestPath,
identityFile: rawArgs.identityFile,
vpsHost: rawArgs.vpsHost,
vpsUser: rawArgs.vpsUser,
archiveSource: rawArgs.archiveSource,
bundleDir: rawArgs.bundleDir,
healthUrl: rawArgs.healthUrl,
waitSeconds: rawArgs.waitSeconds,
keepBundleDir: rawArgs.keepBundleDir,
skipLiveValidation: rawArgs.skipLiveValidation,
json: rawArgs.json,
dryRun: rawArgs.dryRun,
manifest,
gitRef,
})
const deployScript = buildRemoteLiveDeployScript({
checkoutRoot: options.checkoutRoot,
bundleDir: options.bundleDir,
keepBundleDir: options.keepBundleDir,
manifest,
})
const checkoutStageCommand = buildStageCheckoutRemoteCommand(options.checkoutRoot)
const bundleStageCommand = buildStageBundleRemoteCommand(options.bundleDir)
const sanitizedManifest = sanitizeLiveDeployManifest(manifest)
if (options.dryRun) {
const payload = {
repoRoot,
websiteRoot,
manifestPath,
vpsHost: options.vpsHost,
vpsUser: options.vpsUser,
archiveSource: options.archiveSource,
checkoutRoot: options.checkoutRoot,
bundleDir: options.bundleDir,
healthUrl: options.healthUrl,
waitSeconds: options.waitSeconds,
keepBundleDir: options.keepBundleDir,
skipLiveValidation: options.skipLiveValidation,
checkoutStageCommand,
bundleStageCommand,
manifest: sanitizedManifest,
deployScript,
defaultBundleDirForGitRef: buildDefaultLiveDeployBundleDir(gitRef),
}
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
} else {
process.stdout.write(`[live-deploy] manifest: ${manifestPath}\n`)
process.stdout.write(`[live-deploy] vps: ${options.vpsUser}@${options.vpsHost}\n`)
process.stdout.write(`[live-deploy] archive source: ${options.archiveSource}\n`)
process.stdout.write(`[live-deploy] checkout root: ${options.checkoutRoot}\n`)
process.stdout.write(`[live-deploy] bundle dir: ${options.bundleDir}\n`)
process.stdout.write(`[live-deploy] health url: ${options.healthUrl}\n`)
}
return
}
const localBundleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-'))
try {
const rendered = writeSameOriginBundle({
manifest,
outDir: localBundleDir,
})
const frontendEnv = parseEnvFile(rendered.frontendEnvContent)
const serverEnv = parseEnvFile(rendered.serverEnvContent)
const checkoutArchiveBuffer = buildArchiveBuffer(repoRoot, options.archiveSource)
const bundleArchiveBuffer = buildRenderedBundleArchive(localBundleDir)
const checkoutStageResult = runCommand('ssh', buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
remoteCommand: checkoutStageCommand,
}), {
input: checkoutArchiveBuffer,
encoding: 'utf8',
})
if (checkoutStageResult.status !== 0) {
process.stderr.write(checkoutStageResult.stderr || '')
process.exit(checkoutStageResult.status || 1)
}
const bundleStageResult = runCommand('ssh', buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
remoteCommand: bundleStageCommand,
}), {
input: bundleArchiveBuffer,
encoding: 'utf8',
})
if (bundleStageResult.status !== 0) {
process.stderr.write(bundleStageResult.stderr || '')
process.exit(bundleStageResult.status || 1)
}
const deployResult = runCommand('ssh', [
...buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
}),
'bash',
'-s',
], {
input: deployScript,
})
process.stderr.write(deployResult.stderr || '')
const deploySummary = extractLiveDeploySummary(deployResult.stdout || '')
let liveHealth = null
let liveReleaseManifest = null
let liveWebsiteShell = null
const attemptedLiveValidation = !options.skipLiveValidation
if (!options.skipLiveValidation) {
liveHealth = await waitForLiveAuthHealth(options.healthUrl, options.waitSeconds)
liveReleaseManifest = await fetchLiveReleaseManifest(options.healthUrl)
liveWebsiteShell = await fetchLiveWebsiteShell(options.healthUrl)
}
const readinessReport = buildRuntimeReadinessReport({
frontendEnv,
serverEnv,
liveHealth,
liveReleaseManifest,
liveWebsiteShell,
liveHealthAttempted: attemptedLiveValidation,
liveReleaseManifestAttempted: attemptedLiveValidation,
liveWebsiteShellAttempted: attemptedLiveValidation,
frontendEnvPath: path.join(localBundleDir, 'website.env'),
serverEnvPath: path.join(localBundleDir, 'server.env'),
healthBaseUrl: options.healthUrl,
deploymentTier: manifest.deploymentTier || manifest?.server?.deploymentTier || '',
})
const payload = {
ok: deployResult.status === 0 && readinessReport.ok,
manifestPath,
vpsHost: options.vpsHost,
vpsUser: options.vpsUser,
archiveSource: options.archiveSource,
checkoutRoot: options.checkoutRoot,
bundleDir: options.bundleDir,
healthUrl: options.healthUrl,
deploySummary,
readinessReport,
liveHealth,
liveReleaseManifest,
liveWebsiteShell,
}
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
} else {
if (deploySummary) {
process.stdout.write(`[live-deploy] service status: ${deploySummary.serviceStatus}\n`)
process.stdout.write(`[live-deploy] nginx status: ${deploySummary.nginxStatus}\n`)
}
process.stdout.write(`${formatRuntimeReadinessReport(readinessReport)}\n`)
}
if (deployResult.status !== 0 || !readinessReport.ok) {
process.exit(deployResult.status || 1)
}
} finally {
fs.rmSync(localBundleDir, { recursive: true, force: true })
}
}
main().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})