#!/usr/bin/env node import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = process.cwd(); const scriptDir = path.dirname(resolveInvokedScriptPath()); const releaseRoot = path.resolve(scriptDir, '..'); const DEFAULT_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS = 24 * 60 * 60 * 1000; const DEFAULT_CUTOVER_RUN_ID_PREFIX = 'pingora-direct-'; const SECRET_VALUE_FLAGS = new Set([ '--direct-probe-token', '--probe-token', '--pingora-shadow-probe-token', '--rollback-pingora-shadow-probe-token', ]); const config = parseArgs(process.argv.slice(2)); const failures = []; if (config.dryRunPlan) { printDryRunPlan(buildSteps(config)); process.exit(0); } if (config.dryRunCutover) { printDryRunPlan(buildCutoverPlan(config)); process.exit(0); } for (const step of buildSteps(config)) { const ok = await runStep(step); if (!ok && !config.keepGoing) { break; } } if (failures.length > 0) { console.error('\n[pingora-release-readiness] 未通过:'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('\n[pingora-release-readiness] 通过'); function resolveInvokedScriptPath() { const invoked = process.argv[1]; if (invoked) { return path.resolve(invoked); } return fileURLToPath(import.meta.url); } function parseArgs(argv) { const result = { requireDocker: false, pullDocker: false, requireNginx: false, requireLive: false, requireRealpathLive: false, requireDirect: false, liveBaseUrl: process.env.GENARRATIVE_PINGORA_CANARY_BASE_URL || '', liveHost: process.env.GENARRATIVE_PINGORA_CANARY_HOST || '', liveAccessLogNginx: process.env.GENARRATIVE_PINGORA_CANARY_NGINX_ACCESS_LOG || '/var/log/nginx/genarrative.access.log', liveAccessLogPingora: process.env.GENARRATIVE_PINGORA_CANARY_PINGORA_ACCESS_LOG || '/var/log/genarrative/pingora-gateway.access.log', liveAccessLogSinceLines: process.env.GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES || '2000', realpathLiveBaseUrl: process.env.GENARRATIVE_PINGORA_REALPATH_CANARY_BASE_URL || '', realpathLiveHost: process.env.GENARRATIVE_PINGORA_REALPATH_CANARY_HOST || '', realpathLiveAccessLogNginx: process.env.GENARRATIVE_PINGORA_REALPATH_CANARY_NGINX_ACCESS_LOG || '/var/log/nginx/genarrative-pingora-realpath-canary.access.log', realpathLiveAccessLogPingora: process.env.GENARRATIVE_PINGORA_REALPATH_CANARY_PINGORA_ACCESS_LOG || process.env.GENARRATIVE_PINGORA_CANARY_PINGORA_ACCESS_LOG || '/var/log/genarrative/pingora-gateway.access.log', realpathLiveAccessLogSinceLines: process.env.GENARRATIVE_PINGORA_REALPATH_CANARY_ACCESS_LOG_SINCE_LINES || process.env.GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES || '2000', directHttpsBaseUrl: process.env.GENARRATIVE_PINGORA_DIRECT_HTTPS_BASE_URL || '', directHttpBaseUrl: process.env.GENARRATIVE_PINGORA_DIRECT_HTTP_BASE_URL || '', directHost: process.env.GENARRATIVE_PINGORA_DIRECT_HOST || '', directRedirectHost: process.env.GENARRATIVE_PINGORA_DIRECT_REDIRECT_HOST || '', directRedirectBaseUrl: process.env.GENARRATIVE_PINGORA_DIRECT_REDIRECT_BASE_URL || '', directProbeToken: process.env.GENARRATIVE_PINGORA_DIRECT_PROBE_TOKEN || '', directPingoraAccessLog: process.env.GENARRATIVE_PINGORA_DIRECT_PINGORA_ACCESS_LOG || '', directAccessLogSinceLines: process.env.GENARRATIVE_PINGORA_DIRECT_ACCESS_LOG_SINCE_LINES || '2000', directPreflightEnvFile: process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE || '', directPreflightSystemd: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SYSTEMD_CAT', ), directPreflightCheckCertReadable: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_CERT_READABLE', ), directPreflightCheckServiceEnvFile: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_ENV_FILE', ), directPreflightCheckServiceUserCertReadable: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_USER_CERT_READABLE', ), directPreflightCheckServiceBinaryExecutable: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_BINARY_EXECUTABLE', ), directPreflightCheckPortsFree: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE', ), directSpacetimeDatabase: process.env.GENARRATIVE_PINGORA_DIRECT_SPACETIME_DATABASE || '', directHealthPatrolEnvFile: process.env.GENARRATIVE_PINGORA_DIRECT_HEALTH_PATROL_ENV_FILE || process.env.GENARRATIVE_HEALTH_PATROL_ENV_FILE || '', directRequireWssUpgrade: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_REQUIRE_WSS_UPGRADE', ), directSkipWss: readBoolEnv('GENARRATIVE_PINGORA_DIRECT_SKIP_WSS'), directInsecureTls: readBoolEnv( 'GENARRATIVE_PINGORA_DIRECT_INSECURE_TLS', ), cutoverReleaseRoot: process.env.GENARRATIVE_PINGORA_CUTOVER_RELEASE_ROOT || '/opt/genarrative/current', cutoverEvidenceOutputRoot: process.env.GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_ROOT || '/var/log/genarrative/pingora-cutover-evidence', cutoverEvidenceTimelineMaxSpanMs: parsePositiveIntEnv( 'GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS', DEFAULT_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS, ), cutoverRunId: process.env.GENARRATIVE_PINGORA_CUTOVER_RUN_ID || '', rollbackNginxSmokeUrl: process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_URL || '', rollbackNginxSmokeHost: process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_HOST || '', rollbackNginxSmokeExpectBody: process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_EXPECT_BODY || '', rollbackHealthPatrolPublicBaseUrl: process.env.GENARRATIVE_PINGORA_ROLLBACK_HEALTH_PATROL_PUBLIC_BASE_URL || '', rollbackHealthPatrolPublicHost: process.env.GENARRATIVE_PINGORA_ROLLBACK_HEALTH_PATROL_PUBLIC_HOST || '', rollbackPingoraShadowProbeUrl: process.env.GENARRATIVE_PINGORA_ROLLBACK_SHADOW_PROBE_URL || '', rollbackPingoraShadowProbeToken: process.env.GENARRATIVE_PINGORA_ROLLBACK_SHADOW_PROBE_TOKEN || '', dryRunPlan: false, dryRunCutover: false, releaseRuntimeOnly: readBoolEnv( 'GENARRATIVE_PINGORA_RELEASE_RUNTIME_ONLY', ), keepGoing: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; switch (arg) { case '-h': case '--help': printUsage(); process.exit(0); break; case '--require-docker': result.requireDocker = true; break; case '--pull-docker': result.pullDocker = true; break; case '--require-nginx': result.requireNginx = true; break; case '--require-live': result.requireLive = true; break; case '--require-realpath-live': result.requireRealpathLive = true; break; case '--require-direct': result.requireDirect = true; break; case '--live-base-url': result.liveBaseUrl = requireValue(argv, ++index, arg); break; case '--live-host': result.liveHost = requireValue(argv, ++index, arg); break; case '--live-nginx-access-log': result.liveAccessLogNginx = requireValue(argv, ++index, arg); break; case '--live-pingora-access-log': result.liveAccessLogPingora = requireValue(argv, ++index, arg); break; case '--live-access-log-since-lines': result.liveAccessLogSinceLines = requireValue(argv, ++index, arg); break; case '--realpath-live-base-url': result.realpathLiveBaseUrl = requireValue(argv, ++index, arg); break; case '--realpath-live-host': result.realpathLiveHost = requireValue(argv, ++index, arg); break; case '--realpath-live-nginx-access-log': result.realpathLiveAccessLogNginx = requireValue(argv, ++index, arg); break; case '--realpath-live-pingora-access-log': result.realpathLiveAccessLogPingora = requireValue(argv, ++index, arg); break; case '--realpath-live-access-log-since-lines': result.realpathLiveAccessLogSinceLines = requireValue(argv, ++index, arg); break; case '--direct-https-base-url': result.directHttpsBaseUrl = requireValue(argv, ++index, arg); break; case '--direct-http-base-url': result.directHttpBaseUrl = requireValue(argv, ++index, arg); break; case '--direct-host': result.directHost = requireValue(argv, ++index, arg); break; case '--direct-redirect-host': result.directRedirectHost = requireValue(argv, ++index, arg); break; case '--direct-redirect-base-url': result.directRedirectBaseUrl = requireValue(argv, ++index, arg); break; case '--direct-probe-token': result.directProbeToken = requireValue(argv, ++index, arg); break; case '--direct-pingora-access-log': result.directPingoraAccessLog = requireValue(argv, ++index, arg); break; case '--direct-access-log-since-lines': result.directAccessLogSinceLines = requireValue(argv, ++index, arg); break; case '--direct-preflight-env-file': result.directPreflightEnvFile = requireValue(argv, ++index, arg); break; case '--direct-preflight-systemd': result.directPreflightSystemd = true; break; case '--direct-preflight-check-cert-readable': result.directPreflightCheckCertReadable = true; break; case '--direct-preflight-check-service-env-file': result.directPreflightCheckServiceEnvFile = true; break; case '--direct-preflight-check-service-user-cert-readable': result.directPreflightCheckServiceUserCertReadable = true; break; case '--direct-preflight-check-service-binary-executable': result.directPreflightCheckServiceBinaryExecutable = true; break; case '--direct-preflight-check-ports-free': result.directPreflightCheckPortsFree = true; break; case '--direct-spacetime-database': result.directSpacetimeDatabase = requireValue(argv, ++index, arg); break; case '--direct-health-patrol-env-file': result.directHealthPatrolEnvFile = requireValue(argv, ++index, arg); break; case '--direct-require-wss-upgrade': result.directRequireWssUpgrade = true; break; case '--direct-skip-wss': result.directSkipWss = true; break; case '--direct-insecure-tls': result.directInsecureTls = true; break; case '--cutover-release-root': result.cutoverReleaseRoot = requireValue(argv, ++index, arg); break; case '--cutover-evidence-output-root': result.cutoverEvidenceOutputRoot = requireValue(argv, ++index, arg); break; case '--cutover-evidence-timeline-max-span-ms': result.cutoverEvidenceTimelineMaxSpanMs = parsePositiveInt( requireValue(argv, ++index, arg), '--cutover-evidence-timeline-max-span-ms', ); break; case '--cutover-run-id': result.cutoverRunId = requireValue(argv, ++index, arg); break; case '--rollback-nginx-smoke-url': result.rollbackNginxSmokeUrl = requireValue(argv, ++index, arg); break; case '--rollback-nginx-smoke-host': result.rollbackNginxSmokeHost = requireValue(argv, ++index, arg); break; case '--rollback-nginx-smoke-expect-body': result.rollbackNginxSmokeExpectBody = requireValue(argv, ++index, arg); break; case '--rollback-health-patrol-public-base-url': result.rollbackHealthPatrolPublicBaseUrl = requireValue( argv, ++index, arg, ); break; case '--rollback-health-patrol-public-host': result.rollbackHealthPatrolPublicHost = requireValue( argv, ++index, arg, ); break; case '--rollback-pingora-shadow-probe-url': result.rollbackPingoraShadowProbeUrl = requireValue(argv, ++index, arg); break; case '--rollback-pingora-shadow-probe-token': result.rollbackPingoraShadowProbeToken = requireValue( argv, ++index, arg, ); break; case '--dry-run-plan': result.dryRunPlan = true; break; case '--dry-run-cutover': result.dryRunCutover = true; break; case '--release-runtime-only': result.releaseRuntimeOnly = true; break; case '--keep-going': result.keepGoing = true; break; default: throw new Error(`未知参数: ${arg}`); } } if (result.requireLive && !result.liveBaseUrl) { throw new Error( '启用 --require-live 时必须提供 --live-base-url 或 GENARRATIVE_PINGORA_CANARY_BASE_URL', ); } if (result.requireLive && !result.liveHost) { throw new Error( '启用 --require-live 时必须提供 --live-host 或 GENARRATIVE_PINGORA_CANARY_HOST', ); } if (result.liveHost) { validateHostOption(result.liveHost, '--live-host'); } if (result.requireLive) { for (const [label, filePath] of [ ['--live-nginx-access-log', result.liveAccessLogNginx], ['--live-pingora-access-log', result.liveAccessLogPingora], ]) { validateSafeAbsoluteFilePath(filePath, label); } if (!isPositiveIntegerString(result.liveAccessLogSinceLines)) { throw new Error('--live-access-log-since-lines 必须是正整数。'); } } if (result.requireRealpathLive && !result.realpathLiveBaseUrl) { throw new Error( '启用 --require-realpath-live 时必须提供 --realpath-live-base-url 或 GENARRATIVE_PINGORA_REALPATH_CANARY_BASE_URL', ); } if (result.requireRealpathLive && !result.realpathLiveHost) { throw new Error( '启用 --require-realpath-live 时必须提供 --realpath-live-host 或 GENARRATIVE_PINGORA_REALPATH_CANARY_HOST', ); } if (result.realpathLiveHost) { validateHostOption(result.realpathLiveHost, '--realpath-live-host'); } if (result.requireRealpathLive || result.realpathLiveBaseUrl) { for (const [label, filePath] of [ ['--realpath-live-nginx-access-log', result.realpathLiveAccessLogNginx], ['--realpath-live-pingora-access-log', result.realpathLiveAccessLogPingora], ]) { validateSafeAbsoluteFilePath(filePath, label); } if (!isPositiveIntegerString(result.realpathLiveAccessLogSinceLines)) { throw new Error('--realpath-live-access-log-since-lines 必须是正整数。'); } } if (result.requireDirect && !result.directHttpsBaseUrl) { throw new Error( '启用 --require-direct 时必须提供 --direct-https-base-url 或 GENARRATIVE_PINGORA_DIRECT_HTTPS_BASE_URL', ); } if (result.requireDirect && !result.directHttpBaseUrl) { throw new Error( '启用 --require-direct 时必须提供 --direct-http-base-url 或 GENARRATIVE_PINGORA_DIRECT_HTTP_BASE_URL', ); } if (result.requireDirect && !result.directHost) { throw new Error( '启用 --require-direct 时必须提供 --direct-host 或 GENARRATIVE_PINGORA_DIRECT_HOST', ); } if (result.directHost) { validateHostOption(result.directHost, '--direct-host'); } if (result.requireDirect && !result.directRedirectHost) { throw new Error( '启用 --require-direct 时必须提供 --direct-redirect-host 或 GENARRATIVE_PINGORA_DIRECT_REDIRECT_HOST', ); } if (result.directRedirectHost) { validateHostOption(result.directRedirectHost, '--direct-redirect-host'); } if (result.directRedirectBaseUrl) { validateHttpUrl(result.directRedirectBaseUrl, '--direct-redirect-base-url'); const parsed = new URL(result.directRedirectBaseUrl); if (parsed.protocol !== 'https:') { throw new Error('--direct-redirect-base-url 必须使用 https://。'); } if ( parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username || parsed.password ) { throw new Error( '--direct-redirect-base-url 只能是 HTTPS base URL,不能包含路径、查询、片段或认证信息。', ); } } if (result.requireDirect && !result.directPingoraAccessLog) { throw new Error( '启用 --require-direct 时必须提供 --direct-pingora-access-log 或 GENARRATIVE_PINGORA_DIRECT_PINGORA_ACCESS_LOG', ); } if (result.directPingoraAccessLog) { validateSafeAbsoluteFilePath( result.directPingoraAccessLog, '--direct-pingora-access-log', ); } if ( result.directPingoraAccessLog && !isPositiveIntegerString(result.directAccessLogSinceLines) ) { throw new Error('--direct-access-log-since-lines 必须是正整数。'); } if (result.requireDirect && !result.directPreflightEnvFile) { throw new Error( '启用 --require-direct 时必须提供 --direct-preflight-env-file 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE', ); } if (result.directPreflightEnvFile) { validateSafeAbsoluteFilePath( result.directPreflightEnvFile, '--direct-preflight-env-file', ); } if (result.requireDirect && !result.directPreflightSystemd) { throw new Error( '启用 --require-direct 时必须提供 --direct-preflight-systemd 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SYSTEMD_CAT=true', ); } if (result.requireDirect && !result.directPreflightCheckCertReadable) { throw new Error( '启用 --require-direct 时必须提供 --direct-preflight-check-cert-readable 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_CERT_READABLE=true', ); } if (result.requireDirect && !result.directPreflightCheckServiceEnvFile) { throw new Error( '启用 --require-direct 时必须提供 --direct-preflight-check-service-env-file 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_ENV_FILE=true', ); } if ( result.requireDirect && !result.directPreflightCheckServiceUserCertReadable ) { throw new Error( '启用 --require-direct 时必须提供 --direct-preflight-check-service-user-cert-readable 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_USER_CERT_READABLE=true', ); } if ( result.requireDirect && !result.directPreflightCheckServiceBinaryExecutable ) { throw new Error( '启用 --require-direct 时必须提供 --direct-preflight-check-service-binary-executable 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_BINARY_EXECUTABLE=true', ); } if (result.dryRunCutover && !result.directPreflightCheckPortsFree) { throw new Error( '启用 --dry-run-cutover 时必须提供 --direct-preflight-check-ports-free 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE=true,用于切换前证明 Nginx 和其它进程已释放 80/443。', ); } if (result.requireDirect && result.directSkipWss) { throw new Error( '启用 --require-direct 时不能使用 --direct-skip-wss 或 GENARRATIVE_PINGORA_DIRECT_SKIP_WSS=true', ); } if (result.requireDirect && !result.directSpacetimeDatabase) { throw new Error( '启用 --require-direct 时必须提供 --direct-spacetime-database 或 GENARRATIVE_PINGORA_DIRECT_SPACETIME_DATABASE', ); } if (result.requireDirect && !result.directHealthPatrolEnvFile) { throw new Error( '启用 --require-direct 时必须提供 --direct-health-patrol-env-file 或 GENARRATIVE_PINGORA_DIRECT_HEALTH_PATROL_ENV_FILE', ); } if (result.directHealthPatrolEnvFile) { validateSafeAbsoluteFilePath( result.directHealthPatrolEnvFile, '--direct-health-patrol-env-file', ); } if (result.requireDirect && result.directInsecureTls) { throw new Error( '启用 --require-direct 时不能使用 --direct-insecure-tls 或 GENARRATIVE_PINGORA_DIRECT_INSECURE_TLS=true', ); } if (result.requireDirect && !result.directSkipWss) { result.directRequireWssUpgrade = true; } if (result.dryRunCutover && !result.requireDirect) { throw new Error( '启用 --dry-run-cutover 时必须同时提供 --require-direct,避免生成缺少直连硬门禁的切换计划。', ); } if (result.dryRunCutover && !path.isAbsolute(result.cutoverReleaseRoot)) { throw new Error('--cutover-release-root 必须是绝对路径。'); } if ( result.dryRunCutover && isFilesystemRootPath(result.cutoverReleaseRoot) ) { throw new Error('--cutover-release-root 不能是文件系统根目录。'); } if ( result.dryRunCutover && !path.isAbsolute(result.cutoverEvidenceOutputRoot) ) { throw new Error('--cutover-evidence-output-root 必须是绝对路径。'); } if ( result.dryRunCutover && isFilesystemRootPath(result.cutoverEvidenceOutputRoot) ) { throw new Error('--cutover-evidence-output-root 不能是文件系统根目录。'); } if (result.dryRunCutover && !result.rollbackNginxSmokeUrl) { throw new Error( '启用 --dry-run-cutover 时必须提供 --rollback-nginx-smoke-url 或 GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_URL。', ); } if (result.dryRunCutover && !result.rollbackNginxSmokeExpectBody) { throw new Error( '启用 --dry-run-cutover 时必须提供 --rollback-nginx-smoke-expect-body 或 GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_EXPECT_BODY,且该片段必须来自切换前真实 Nginx 入口。', ); } if (result.dryRunCutover && !result.rollbackNginxSmokeHost) { result.rollbackNginxSmokeHost = result.directHost || result.liveHost; } if (result.rollbackNginxSmokeHost) { validateHostOption( result.rollbackNginxSmokeHost, '--rollback-nginx-smoke-host', ); } if ( result.dryRunCutover && !result.rollbackHealthPatrolPublicBaseUrl ) { throw new Error( '启用 --dry-run-cutover 时必须提供 --rollback-health-patrol-public-base-url 或 GENARRATIVE_PINGORA_ROLLBACK_HEALTH_PATROL_PUBLIC_BASE_URL,避免回退 runbook 覆盖现场原有 Nginx 巡检入口。', ); } if (result.rollbackHealthPatrolPublicBaseUrl) { validateHttpUrl( result.rollbackHealthPatrolPublicBaseUrl, '--rollback-health-patrol-public-base-url', ); } if (result.rollbackHealthPatrolPublicHost) { validateHostOption( result.rollbackHealthPatrolPublicHost, '--rollback-health-patrol-public-host', ); } if ( result.rollbackPingoraShadowProbeUrl && !result.rollbackPingoraShadowProbeToken ) { throw new Error( '--rollback-pingora-shadow-probe-url 必须同时提供 --rollback-pingora-shadow-probe-token。', ); } if ( result.rollbackPingoraShadowProbeToken && !result.rollbackPingoraShadowProbeUrl ) { throw new Error( '--rollback-pingora-shadow-probe-token 必须同时提供 --rollback-pingora-shadow-probe-url。', ); } if (result.rollbackPingoraShadowProbeUrl) { validateHttpUrl( result.rollbackPingoraShadowProbeUrl, '--rollback-pingora-shadow-probe-url', ); } if (result.dryRunCutover) { assertSameCutoverHostname( result.directRedirectHost, result.directHost, '--direct-redirect-host', '--direct-host', ); assertSameCutoverHostname( result.rollbackNginxSmokeHost, result.directHost, '--rollback-nginx-smoke-host', '--direct-host', ); if (result.liveHost) { assertSameCutoverHostname( result.liveHost, result.directHost, '--live-host', '--direct-host', ); } } if (result.dryRunCutover && !result.cutoverRunId) { result.cutoverRunId = generateCutoverRunId(); } if (result.cutoverRunId) { validateSafeName(result.cutoverRunId, '--cutover-run-id'); } if ( result.releaseRuntimeOnly && (result.requireDocker || result.pullDocker || result.requireNginx) ) { throw new Error( '--release-runtime-only 只执行 current release 包内运行时复核,不能同时使用 --require-docker / --pull-docker / --require-nginx;源码全量门禁请在构建环境运行默认模式。', ); } return result; } function printUsage() { console.log(`Usage: node scripts/check-pingora-release-readiness.mjs [options] Options: --require-docker Docker Nginx handoff smoke 必须执行通过,不允许跳过。 --pull-docker Docker 镜像缺失时允许拉取;通常和 --require-docker 一起用于 CI。 --require-nginx 本机 Nginx snippet 校验必须执行 nginx -t。 --require-live 目标 Nginx 已启用 canary 后,强制执行 live canary smoke。 --require-realpath-live 目标 Nginx 已启用真实路径 canary 后,强制执行 realpath live canary smoke。 --require-direct 目标 Pingora 已配置直连 HTTPS / HTTP redirect 后,强制执行 direct preflight 和 direct live smoke。 --live-base-url live canary base URL;也可用 GENARRATIVE_PINGORA_CANARY_BASE_URL。 --live-host live canary Host header;也可用 GENARRATIVE_PINGORA_CANARY_HOST。--require-live 时必须显式提供。 --live-nginx-access-log live canary 后 Nginx access log 对账路径,默认 /var/log/nginx/genarrative.access.log。 --live-pingora-access-log live canary 后 Pingora access log 对账路径,默认 /var/log/genarrative/pingora-gateway.access.log。 --live-access-log-since-lines live canary 后只读取日志尾部行数,默认 2000。 --realpath-live-base-url 真实路径 canary base URL;也可用 GENARRATIVE_PINGORA_REALPATH_CANARY_BASE_URL。 --realpath-live-host 真实路径 canary Host header;也可用 GENARRATIVE_PINGORA_REALPATH_CANARY_HOST。--require-realpath-live 时必须显式提供。 --realpath-live-nginx-access-log 真实路径 canary 独立 Nginx access log,默认 /var/log/nginx/genarrative-pingora-realpath-canary.access.log。 --realpath-live-pingora-access-log 真实路径 canary 后 Pingora access log 对账路径,默认 /var/log/genarrative/pingora-gateway.access.log。 --realpath-live-access-log-since-lines 真实路径 canary 后只读取日志尾部行数,默认 2000。 --direct-https-base-url direct HTTPS base URL;也可用 GENARRATIVE_PINGORA_DIRECT_HTTPS_BASE_URL。 --direct-http-base-url direct HTTP redirect base URL;也可用 GENARRATIVE_PINGORA_DIRECT_HTTP_BASE_URL。 --direct-host direct live Host header 和 TLS SNI;也可用 GENARRATIVE_PINGORA_DIRECT_HOST。--require-direct 时必须显式提供。 --direct-redirect-host direct redirect Location host;也可用 GENARRATIVE_PINGORA_DIRECT_REDIRECT_HOST。--require-direct 时必须显式提供,cutover runbook 中必须和 --direct-host 使用同一 hostname。 --direct-redirect-base-url 可选 direct redirect Location HTTPS base URL;用于高端口 rehearsal 访问 127.0.0.1:18443 但期望 Location 指向正式 HTTPS 入口。 --direct-probe-token direct live shadow probe token;也可用 GENARRATIVE_PINGORA_DIRECT_PROBE_TOKEN。 --direct-pingora-access-log direct live 后 Pingora access log 落盘校验路径;--require-direct 时必须显式提供。 --direct-access-log-since-lines direct live 后只读取 Pingora access log 尾部行数,默认 2000。 --direct-preflight-env-file direct entry 预检使用的 pingora-gateway.env;也可用 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE。 --direct-preflight-systemd direct entry 预检要求 systemctl cat 已包含低端口 capability drop-in。 --direct-preflight-check-cert-readable direct entry 预检要求当前用户可读 cert/key。 --direct-preflight-check-service-env-file direct entry 预检要求 service EnvironmentFile 包含本次 env 文件;--require-direct 时必须显式提供。 --direct-preflight-check-service-user-cert-readable direct entry 预检要求 systemd 服务用户可读 cert/key;--require-direct 时必须显式提供。 --direct-preflight-check-service-binary-executable direct entry 预检要求 service ExecStart 指向的 pingora-gateway 已存在且可执行;--require-direct 时必须显式提供。 --direct-preflight-check-ports-free direct entry 预检要求 TLS/HTTP redirect 端口当前可绑定;--dry-run-cutover / direct enable apply 前必须显式提供,启用后 --require-direct 复核不要求端口空闲。 --direct-spacetime-database direct live WSS subscribe 使用的数据库名;也可用 GENARRATIVE_PINGORA_DIRECT_SPACETIME_DATABASE。--require-direct 时必须显式提供。 --direct-health-patrol-env-file 目标机 health-patrol env 文件;--require-direct 时必须显式提供,并复核 gateway mode / public base URL / Host。 --direct-require-wss-upgrade 要求 direct live WSS subscribe 必须返回 101;--require-direct 时会自动启用。 --direct-skip-wss 跳过 direct live WSS subscribe 握手;只允许单独 direct live 临时排障,--require-direct 会拒绝。 --direct-insecure-tls direct live 允许自签证书;仅本机 smoke 使用。 --cutover-release-root 生成切换 runbook 时使用的 current release 根目录,默认 /opt/genarrative/current。 --cutover-evidence-output-root 生成切换 runbook 时传给证据包脚本的输出根目录,默认 /var/log/genarrative/pingora-cutover-evidence。 --cutover-evidence-timeline-max-span-ms 生成切换 runbook 时传给证据根目录总审计的标准五段时间线最大跨度,默认 ${DEFAULT_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS}ms。 --cutover-run-id 生成切换 runbook 时写入所有证据 manifest 的本次切换批次 ID;不传则自动生成 ${DEFAULT_CUTOVER_RUN_ID_PREFIX}。 --rollback-nginx-smoke-url 生成切换 runbook 时传给回退脚本的 Nginx smoke URL;必须显式填写切换前真实 Nginx 入口。 --rollback-nginx-smoke-host 生成切换 runbook 时传给回退脚本的 Host header;默认复用 --direct-host,显式传入时必须和 --direct-host 使用同一 hostname。 --rollback-nginx-smoke-expect-body 生成切换 runbook 时传给回退脚本的 Nginx smoke 响应体预期片段;必须显式来自切换前真实响应,例如首页 。 --rollback-health-patrol-public-base-url 生成切换 runbook 时回退后恢复的 Nginx public base URL;应填切换前 health-patrol env 的原值。 --rollback-health-patrol-public-host 生成切换 runbook 时回退后恢复的 public Host;不传则要求清空 Host 覆盖。 --rollback-pingora-shadow-probe-url 生成切换 runbook 时传给回退脚本的可选 Pingora shadow 探针 URL;必须和 token 成对出现。 --rollback-pingora-shadow-probe-token 生成切换 runbook 时传给回退脚本的可选 Pingora shadow 探针 token;JSON 输出会隐藏 token 值。 --dry-run-plan 只打印将执行的检查计划,不启动检查命令。 --dry-run-cutover 只打印直连切换 runbook,不启动检查或修改系统。 --release-runtime-only 只执行 current release 包内运行时复核:current release 自审、live canary、access log 对账、direct preflight、health patrol env 和 direct live;不运行 npm/cargo 源码门禁。 --keep-going 单项失败后继续执行后续检查。 默认模式用于本机提交前检查;正式切换窗口建议: node scripts/check-pingora-release-readiness.mjs --require-docker --pull-docker --require-nginx --require-live --live-base-url http://127.0.0.1 --live-host <域名> Pingora 直连入口切换窗口追加: node scripts/check-pingora-release-readiness.mjs --require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable 只生成直连切换 runbook: node scripts/check-pingora-release-readiness.mjs --dry-run-cutover --require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable --direct-preflight-check-ports-free --cutover-evidence-output-root /var/log/genarrative/pingora-cutover-evidence --rollback-nginx-smoke-url https://<域名>/ --rollback-nginx-smoke-expect-body '' --rollback-health-patrol-public-base-url http://127.0.0.1 `); } function requireValue(argv, index, flag) { const value = argv[index]; if (!value || value.startsWith('--')) { throw new Error(`${flag} 缺少参数值`); } return value; } function readBoolEnv(name, fallback = false) { const raw = process.env[name]; if (raw === undefined || raw === null || String(raw).trim() === '') { return fallback; } const normalized = String(raw).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) { return true; } if (['0', 'false', 'no', 'off'].includes(normalized)) { return false; } throw new Error(`${name} 必须是布尔值 true/false 或 1/0。`); } function parsePositiveIntEnv(name, fallback) { const raw = process.env[name]; if (raw === undefined || raw === null || String(raw).trim() === '') { return fallback; } return parsePositiveInt(raw, name); } function parsePositiveInt(value, label) { const text = String(value || '').trim(); if (!/^[1-9]\d*$/u.test(text)) { throw new Error(`${label} 必须是正整数。`); } const parsed = Number(text); if (!Number.isSafeInteger(parsed)) { throw new Error(`${label} 超出 JavaScript 安全整数范围。`); } return parsed; } function validateHostOption(value, flag) { const raw = String(value); if (raw !== raw.trim() || raw.includes('://') || /[\s/?#@]/.test(raw)) { throw new Error( `${flag} 只能是 host 或 host:port,不能包含 scheme、路径、查询、片段或空白字符`, ); } try { const parsed = new URL(`https://${raw}`); if ( !parsed.hostname || parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username || parsed.password ) { throw new Error('invalid host'); } } catch { throw new Error(`${flag} 不是合法的 host 或 host:port`); } } function validateHttpUrl(value, flag) { let parsed; try { parsed = new URL(value); } catch { throw new Error(`${flag} 必须是 http(s) URL: ${value}`); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`${flag} 必须是 http(s) URL: ${value}`); } } function isPositiveIntegerString(value) { return /^[1-9]\d*$/u.test(String(value)); } function validateSafeAbsoluteFilePath(value, flag) { if (!path.isAbsolute(value)) { throw new Error(`${flag} 必须是绝对路径。`); } if (isFilesystemRootPath(value)) { throw new Error(`${flag} 不能是文件系统根目录。`); } } function isFilesystemRootPath(value) { const resolved = path.resolve(String(value)); return resolved === path.parse(resolved).root; } function hostnameForIdentity(value, flag) { validateHostOption(value, flag); const parsed = new URL(`https://${String(value).trim()}`); return parsed.hostname.replace(/\.$/u, '').toLowerCase(); } function assertSameCutoverHostname(actual, expected, actualFlag, expectedFlag) { const actualHostname = hostnameForIdentity(actual, actualFlag); const expectedHostname = hostnameForIdentity(expected, expectedFlag); if (actualHostname !== expectedHostname) { throw new Error( `${actualFlag} 必须与 ${expectedFlag} 使用同一正式 hostname;当前 ${actualHostname} != ${expectedHostname}`, ); } } function validateSafeName(value, flag) { if (!/^[0-9A-Za-z._-]+$/u.test(String(value || ''))) { throw new Error( `${flag} 只能包含 ASCII 字母、数字、点、下划线或短横线。`, ); } } function generateCutoverRunId(now = new Date()) { const timestamp = now .toISOString() .replace(/[-:.]/gu, '') .replace(/\d{3}Z$/u, 'Z'); return `${DEFAULT_CUTOVER_RUN_ID_PREFIX}${timestamp}`; } function printDryRunPlan(steps) { console.log( JSON.stringify( steps.map((step) => { const output = { name: step.name, command: step.command, args: redactSecretArgs(step.args), }; if (step.phase) { output.phase = step.phase; } if (step.when) { output.when = step.when; } return output; }), null, 2, ), ); } function redactSecretArgs(args) { return args.map((arg, index) => index > 0 && SECRET_VALUE_FLAGS.has(args[index - 1]) ? '' : arg, ); } function buildCutoverPlan(config) { const releaseRoot = config.cutoverReleaseRoot.replace(/\/+$/u, ''); const enableScript = path.join( releaseRoot, 'scripts/deploy/pingora-direct-enable.sh', ); const rollbackScript = path.join( releaseRoot, 'scripts/deploy/pingora-direct-rollback.sh', ); const gatewayEnvShadowSwitchScript = path.join( releaseRoot, 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs', ); const healthPatrolEnvSwitchScript = path.join( releaseRoot, 'scripts/deploy/pingora-health-patrol-env-switch.mjs', ); const statusSnapshotScript = path.join( releaseRoot, 'scripts/ops/pingora-cutover-status-snapshot.mjs', ); const evidenceBundleScript = path.join( releaseRoot, 'scripts/ops/pingora-cutover-evidence-bundle.mjs', ); const commandEvidenceScript = path.join( releaseRoot, 'scripts/ops/pingora-cutover-command-evidence.mjs', ); const evidenceVerifyScript = path.join( releaseRoot, 'scripts/ops/pingora-cutover-evidence-verify.mjs', ); const evidenceAuditScript = path.join( releaseRoot, 'scripts/ops/pingora-cutover-evidence-audit.mjs', ); const currentReleaseAuditScript = path.join( releaseRoot, 'scripts/ops/pingora-current-release-audit.mjs', ); const releaseReadinessScript = path.join( releaseRoot, 'scripts/check-pingora-release-readiness.mjs', ); const releasePreflightScript = path.join( releaseRoot, 'scripts/check-pingora-direct-preflight.mjs', ); const releaseLiveScript = path.join( releaseRoot, 'scripts/check-pingora-direct-live.mjs', ); const preEnablePreflightArgs = [ releasePreflightScript, '--env-file', config.directPreflightEnvFile, '--require-live-env', ...(config.directPreflightSystemd ? ['--systemd-cat'] : []), '--check-cert-readable', '--check-service-env-file', '--check-service-user-cert-readable', '--check-service-binary-executable', '--check-ports-free', ]; const enableBaseArgs = [ '--current-release-audit-script', currentReleaseAuditScript, '--current-release-root', releaseRoot, '--preflight-script', releasePreflightScript, '--preflight-env-file', config.directPreflightEnvFile, '--preflight-check-cert-readable', '--preflight-check-service-env-file', '--preflight-check-service-user-cert-readable', '--preflight-check-service-binary-executable', '--preflight-check-ports-free', '--direct-live-script', releaseLiveScript, '--direct-https-base-url', config.directHttpsBaseUrl, '--direct-http-base-url', config.directHttpBaseUrl, '--direct-host', config.directHost, '--direct-redirect-host', config.directRedirectHost, ...(config.directRedirectBaseUrl ? ['--direct-redirect-base-url', config.directRedirectBaseUrl] : []), '--direct-spacetime-database', config.directSpacetimeDatabase, '--direct-pingora-access-log', config.directPingoraAccessLog, '--direct-access-log-since-lines', config.directAccessLogSinceLines, '--no-status', ]; if (config.directProbeToken) { enableBaseArgs.push('--direct-probe-token', config.directProbeToken); } const rollbackBaseArgs = [ '--reload-nginx', '--nginx-smoke-url', config.rollbackNginxSmokeUrl, '--nginx-smoke-expect-body', config.rollbackNginxSmokeExpectBody, '--health-patrol-env-file', config.directHealthPatrolEnvFile, '--health-patrol-expected-public-base-url', config.rollbackHealthPatrolPublicBaseUrl, ]; if (config.rollbackNginxSmokeHost) { rollbackBaseArgs.push('--nginx-smoke-host', config.rollbackNginxSmokeHost); } if (config.rollbackHealthPatrolPublicHost) { rollbackBaseArgs.push( '--health-patrol-expected-public-host', config.rollbackHealthPatrolPublicHost, ); } else { rollbackBaseArgs.push('--health-patrol-require-empty-public-host'); } if (config.rollbackPingoraShadowProbeUrl) { rollbackBaseArgs.push( '--pingora-shadow-probe-url', config.rollbackPingoraShadowProbeUrl, '--pingora-shadow-probe-token', config.rollbackPingoraShadowProbeToken, ); } const healthPatrolDirectSwitchArgs = [ '--apply', '--env-file', config.directHealthPatrolEnvFile, '--gateway-mode', 'pingora-direct', '--public-base-url', config.directHttpsBaseUrl, '--public-host', config.directHost, ]; const pingoraGatewayShadowSwitchArgs = [ '--apply', '--env-file', config.directPreflightEnvFile, ]; const healthPatrolNginxSwitchArgs = [ '--apply', '--env-file', config.directHealthPatrolEnvFile, '--gateway-mode', 'nginx', '--public-base-url', config.rollbackHealthPatrolPublicBaseUrl, ...(config.rollbackHealthPatrolPublicHost ? ['--public-host', config.rollbackHealthPatrolPublicHost] : ['--clear-public-host']), ]; return [ { phase: 'preflight', name: '切换 Host 一致性确认', command: 'operator-action', args: [ 'confirm', `cutover-run-id=${config.cutoverRunId}`, `direct-host=${config.directHost}`, `direct-redirect-host=${config.directRedirectHost}`, `direct-redirect-base-url=${config.directRedirectBaseUrl || '-'}`, `health-patrol-public-host=${config.directHost}`, `rollback-nginx-smoke-host=${config.rollbackNginxSmokeHost}`, `rollback-nginx-smoke-expect-body=${config.rollbackNginxSmokeExpectBody}`, `rollback-health-patrol-public-base-url=${config.rollbackHealthPatrolPublicBaseUrl}`, `rollback-health-patrol-public-host=${config.rollbackHealthPatrolPublicHost}`, ], when: 'runbook 生成时已拒绝 hostname 漂移;切换窗口继续人工确认直连、回退 smoke 和回退巡检入口都指向预期目标。', }, { phase: 'preflight', name: '切换前 current release 自包含自审', command: 'node', args: [ '--', currentReleaseAuditScript, '--release-root', releaseRoot, ...(config.requireDirect ? ['--require-pingora-gateway'] : []), '--systemd-show', ], when: '正式启用前执行,只读确认 current release 已携带 Pingora 切换依赖;直连切换时要求 pingora-gateway 存在且 systemd ExecStart 指向 current release。', }, { phase: 'preflight', name: '切换前状态快照证据包', command: 'node', args: [ '--', evidenceBundleScript, '--phase', 'pre-cutover', '--cutover-run-id', config.cutoverRunId, '--release-root', releaseRoot, '--output-root', config.cutoverEvidenceOutputRoot, '--snapshot-script', statusSnapshotScript, '--health-patrol-env-file', config.directHealthPatrolEnvFile, '--pingora-env-file', config.directPreflightEnvFile, '--expected-gateway-mode', 'nginx', '--expected-public-base-url', config.rollbackHealthPatrolPublicBaseUrl, ...(config.rollbackHealthPatrolPublicHost ? ['--expected-public-host', config.rollbackHealthPatrolPublicHost] : ['--require-empty-public-host']), ...(config.requireDirect ? ['--require-pingora-gateway'] : []), '--run-health-patrol', '--fail-on-critical', ], when: '正式启用前执行,只读采集 current release、health patrol env 和 systemd 最终配置,并把 JSON / stdout / stderr / manifest 归档为 Nginx 接流基线证据。', }, { phase: 'preflight', name: '切换前证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: '切换前状态快照证据包生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 manifest.files 中的 sizeBytes / sha256 与实际归档文件一致,且 manifest.summary.status 为 OK。', }, { phase: 'preflight', name: '切换前 current release 直连 env / 证书 / 端口预检', command: 'node', args: ['--', ...preEnablePreflightArgs], }, { phase: 'preflight', name: '切换前 release readiness 基础门禁', command: 'node', args: [ '--', releaseReadinessScript, ...readinessArgs(config, { includeDirect: false }), ], }, { phase: 'enable-dry-run', name: 'Pingora direct enable dry-run', command: enableScript, args: enableBaseArgs, }, { phase: 'enable-apply', name: 'Pingora direct enable apply', command: 'node', args: [ '--', commandEvidenceScript, '--phase', 'enable-apply', '--command-name', 'pingora-direct-enable-apply', '--cutover-run-id', config.cutoverRunId, '--output-root', config.cutoverEvidenceOutputRoot, '--expected-executable', enableScript, '--require-arg', '--apply', '--', enableScript, '--apply', ...enableBaseArgs, ], when: '执行真实 direct enable apply,并把 stdout / stderr / 退出码和脱敏命令记录归档到切换证据目录。', }, { phase: 'enable-apply', name: '启用命令证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: 'enable apply 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。', }, { phase: 'post-enable', name: '切换后 health patrol 切到 Pingora direct', command: 'node', args: [ '--', commandEvidenceScript, '--phase', 'post-enable', '--command-name', 'pingora-health-patrol-direct-env-switch', '--cutover-run-id', config.cutoverRunId, '--output-root', config.cutoverEvidenceOutputRoot, '--expected-executable', healthPatrolEnvSwitchScript, '--require-arg', '--apply', '--require-arg', 'pingora-direct', '--', healthPatrolEnvSwitchScript, ...healthPatrolDirectSwitchArgs, ], when: 'direct enable apply 成功后执行,并用命令证据归档 health patrol env 从 nginx 切到 pingora-direct 的真实 stdout / stderr / 退出码,避免巡检继续要求 nginx.service active。', }, { phase: 'post-enable', name: '切换后 health patrol direct 命令证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: 'health patrol direct env switch 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 env 切换命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。', }, { phase: 'post-enable', name: '启用后 health patrol env 直连复核', command: 'node', args: [ '--', path.join(releaseRoot, 'scripts/check-production-health-patrol-env.mjs'), '--env-file', config.directHealthPatrolEnvFile, '--expected-gateway-mode', 'pingora-direct', '--expected-public-base-url', config.directHttpsBaseUrl, '--expected-public-host', config.directHost, ], }, { phase: 'post-enable', name: '启用后状态快照证据包', command: 'node', args: [ '--', evidenceBundleScript, '--phase', 'post-enable', '--cutover-run-id', config.cutoverRunId, '--release-root', releaseRoot, '--output-root', config.cutoverEvidenceOutputRoot, '--snapshot-script', statusSnapshotScript, '--direct-live-script', releaseLiveScript, '--health-patrol-env-file', config.directHealthPatrolEnvFile, '--pingora-env-file', config.directPreflightEnvFile, '--expected-gateway-mode', 'pingora-direct', '--expected-pingora-env-mode', 'direct', '--expected-public-base-url', config.directHttpsBaseUrl, '--expected-public-host', config.directHost, ...(config.requireDirect ? ['--require-pingora-gateway'] : []), '--run-health-patrol', '--run-direct-live', '--direct-https-base-url', config.directHttpsBaseUrl, '--direct-http-base-url', config.directHttpBaseUrl, '--direct-host', config.directHost, '--direct-redirect-host', config.directRedirectHost, ...(config.directRedirectBaseUrl ? ['--direct-redirect-base-url', config.directRedirectBaseUrl] : []), ...(config.directProbeToken ? ['--direct-probe-token', config.directProbeToken] : []), '--direct-spacetime-database', config.directSpacetimeDatabase, '--direct-pingora-access-log', config.directPingoraAccessLog, '--direct-access-log-since-lines', config.directAccessLogSinceLines, '--fail-on-critical', ], when: 'health patrol env 已切到 pingora-direct 且 env 复核通过后执行,归档 direct 接流状态、direct live 和 Pingora access log request_id 证据包。', }, { phase: 'post-enable', name: '启用后证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: '启用后状态快照证据包生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 snapshot / direct live / stdout / stderr / 命令记录未在归档过程中漂移,且 manifest.summary.status 为 OK。', }, { phase: 'post-enable', name: '启用后 release readiness 直连复核', command: 'node', args: [ '--', releaseReadinessScript, ...readinessArgs(config, { includeDirect: true, includeDirectPortsFree: false, }), ], when: 'Pingora 已接管 80/443 后执行;此阶段不再检查端口空闲,因为端口应由 Pingora 直连入口占用。', }, { phase: 'rollback-dry-run', name: 'Pingora direct rollback dry-run', command: rollbackScript, args: rollbackBaseArgs, when: '启用失败、direct live smoke 失败或业务验证失败时先执行。', }, { phase: 'rollback-prep', name: '回退前 Pingora env 预置回 shadow', command: 'node', args: [ '--', commandEvidenceScript, '--phase', 'rollback-prep', '--command-name', 'pingora-gateway-shadow-env-switch', '--cutover-run-id', config.cutoverRunId, '--output-root', config.cutoverEvidenceOutputRoot, '--expected-executable', gatewayEnvShadowSwitchScript, '--require-arg', '--apply', '--', gatewayEnvShadowSwitchScript, ...pingoraGatewayShadowSwitchArgs, ], when: 'rollback apply 前执行,并用命令证据归档 Pingora gateway env 从 80/443 direct 配置恢复为 shadow 高端口配置;否则回退脚本移除低端口 capability 后重启 Pingora 可能仍按 80/443 配置启动失败。', }, { phase: 'rollback-prep', name: '回退前 Pingora env shadow 命令证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: 'Pingora gateway shadow env switch 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 env 切换命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。', }, { phase: 'rollback-prep', name: '回退前 health patrol 预置回 Nginx', command: 'node', args: [ '--', commandEvidenceScript, '--phase', 'rollback-prep', '--command-name', 'pingora-health-patrol-nginx-env-switch', '--cutover-run-id', config.cutoverRunId, '--output-root', config.cutoverEvidenceOutputRoot, '--expected-executable', healthPatrolEnvSwitchScript, '--require-arg', '--apply', '--require-arg', 'nginx', '--', healthPatrolEnvSwitchScript, ...healthPatrolNginxSwitchArgs, ], when: 'rollback apply 前执行,并用命令证据归档 health patrol env 从 pingora-direct 预置回 nginx,确保回退脚本内置 health patrol env 复核能校验切换前 public base URL / Host。', }, { phase: 'rollback-prep', name: '回退前 health patrol nginx 命令证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: 'health patrol nginx env switch 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 env 切换命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。', }, { phase: 'rollback-apply', name: 'Pingora direct rollback apply', command: 'node', args: [ '--', commandEvidenceScript, '--phase', 'rollback-apply', '--command-name', 'pingora-direct-rollback-apply', '--cutover-run-id', config.cutoverRunId, '--output-root', config.cutoverEvidenceOutputRoot, '--expected-executable', rollbackScript, '--require-arg', '--apply', '--', rollbackScript, '--apply', ...rollbackBaseArgs, ], when: 'health patrol env 预置回 Nginx 且 dry-run 确认后执行,用于回到 Nginx / shadow 入口,并把回退命令 stdout / stderr / 退出码归档。', }, { phase: 'rollback-apply', name: '回退命令证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: 'rollback apply 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认回退命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。', }, { phase: 'post-rollback', name: '回退后 health patrol env Nginx 模式复核', command: 'node', args: [ '--', path.join(releaseRoot, 'scripts/check-production-health-patrol-env.mjs'), '--env-file', config.directHealthPatrolEnvFile, '--expected-gateway-mode', 'nginx', ...(config.rollbackHealthPatrolPublicBaseUrl ? [ '--expected-public-base-url', config.rollbackHealthPatrolPublicBaseUrl, ] : []), ...(config.rollbackHealthPatrolPublicHost ? [ '--expected-public-host', config.rollbackHealthPatrolPublicHost, ] : ['--require-empty-public-host']), ], when: 'health patrol env 已切回 nginx 后执行,阻断 pingora-direct 模式或回退目标漂移。', }, { phase: 'post-rollback', name: '回退后状态快照证据包', command: 'node', args: [ '--', evidenceBundleScript, '--phase', 'post-rollback', '--cutover-run-id', config.cutoverRunId, '--release-root', releaseRoot, '--output-root', config.cutoverEvidenceOutputRoot, '--snapshot-script', statusSnapshotScript, '--health-patrol-env-file', config.directHealthPatrolEnvFile, '--pingora-env-file', config.directPreflightEnvFile, '--expected-gateway-mode', 'nginx', '--expected-pingora-env-mode', 'shadow', '--expected-public-base-url', config.rollbackHealthPatrolPublicBaseUrl, ...(config.rollbackHealthPatrolPublicHost ? ['--expected-public-host', config.rollbackHealthPatrolPublicHost] : ['--require-empty-public-host']), ...(config.requireDirect ? ['--require-pingora-gateway'] : []), '--run-health-patrol', '--fail-on-critical', ], when: 'rollback apply 和回退后 env 复核通过后执行,归档 Nginx 接流状态证据包。', }, { phase: 'post-rollback', name: '回退后证据 manifest 只读验真', command: 'node', args: [ '--', evidenceVerifyScript, '--bundle-dir', '', '--require-summary-ok', ], when: '回退后状态快照证据包生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认回退证据 manifest 和文件内容一致,且 manifest.summary.status 为 OK。', }, { phase: 'post-rollback', name: '切换证据根目录三阶段总审计', command: 'node', args: [ '--', evidenceAuditScript, '--evidence-root', config.cutoverEvidenceOutputRoot, '--verify-script', evidenceVerifyScript, '--require-phase', 'pre-cutover', '--require-phase', 'post-enable', '--require-phase', 'post-rollback', '--require-phase-direct-live-access-log', 'post-enable', '--require-phase-direct-live-static-headers', 'post-enable', '--require-phase-pingora-env-shadow', 'post-rollback', '--require-command', 'enable-apply:pingora-direct-enable-apply', '--require-command', 'post-enable:pingora-health-patrol-direct-env-switch', '--require-command', 'rollback-prep:pingora-gateway-shadow-env-switch', '--require-command', 'rollback-prep:pingora-health-patrol-nginx-env-switch', '--require-command', 'rollback-apply:pingora-direct-rollback-apply', '--require-command-executable', `enable-apply:pingora-direct-enable-apply:${enableScript}`, '--require-command-executable', `post-enable:pingora-health-patrol-direct-env-switch:${healthPatrolEnvSwitchScript}`, '--require-command-executable', `rollback-prep:pingora-gateway-shadow-env-switch:${gatewayEnvShadowSwitchScript}`, '--require-command-executable', `rollback-prep:pingora-health-patrol-nginx-env-switch:${healthPatrolEnvSwitchScript}`, '--require-command-executable', `rollback-apply:pingora-direct-rollback-apply:${rollbackScript}`, '--require-command-arg', 'enable-apply:pingora-direct-enable-apply:--apply', '--require-command-arg', 'post-enable:pingora-health-patrol-direct-env-switch:--apply', '--require-command-arg', 'post-enable:pingora-health-patrol-direct-env-switch:pingora-direct', '--require-command-arg', 'rollback-prep:pingora-gateway-shadow-env-switch:--apply', '--require-command-arg', 'rollback-prep:pingora-health-patrol-nginx-env-switch:--apply', '--require-command-arg', 'rollback-prep:pingora-health-patrol-nginx-env-switch:nginx', '--require-command-arg', 'rollback-apply:pingora-direct-rollback-apply:--apply', '--require-cutover-run-id', config.cutoverRunId, '--timeline-max-span-ms', String(config.cutoverEvidenceTimelineMaxSpanMs), ], when: '三阶段证据包、enable / rollback apply 命令证据和三条 env 变更命令证据都生成并分别验真后执行,自动找每个 phase 与命令的最新 bundleDir 并再次做只读 manifest 验真,且要求 post-enable manifest.summary.directLiveAccessLog 和 directLiveStaticHeaders 可直接复盘 request_id 对账、静态缓存、校验器、Range 和 304 证据,同时要求 post-rollback manifest.summary.pingoraEnvShadow 证明 Pingora env 已恢复 shadow 高端口。', }, ]; } function readinessArgs(config, { includeDirect, includeDirectPortsFree = true }) { return [ '--release-runtime-only', ...(config.requireLive ? [ '--require-live', '--live-base-url', config.liveBaseUrl, '--live-host', config.liveHost, ] : []), ...(config.requireRealpathLive ? [ '--require-realpath-live', '--realpath-live-base-url', config.realpathLiveBaseUrl, '--realpath-live-host', config.realpathLiveHost, '--realpath-live-nginx-access-log', config.realpathLiveAccessLogNginx, '--realpath-live-pingora-access-log', config.realpathLiveAccessLogPingora, '--realpath-live-access-log-since-lines', String(config.realpathLiveAccessLogSinceLines), ] : []), ...(includeDirect ? [ '--require-direct', '--direct-https-base-url', config.directHttpsBaseUrl, '--direct-http-base-url', config.directHttpBaseUrl, '--direct-host', config.directHost, '--direct-redirect-host', config.directRedirectHost, ...(config.directRedirectBaseUrl ? ['--direct-redirect-base-url', config.directRedirectBaseUrl] : []), ...(config.directProbeToken ? ['--direct-probe-token', config.directProbeToken] : []), '--direct-pingora-access-log', config.directPingoraAccessLog, '--direct-access-log-since-lines', config.directAccessLogSinceLines, '--direct-spacetime-database', config.directSpacetimeDatabase, '--direct-health-patrol-env-file', config.directHealthPatrolEnvFile, '--direct-preflight-env-file', config.directPreflightEnvFile, '--direct-preflight-systemd', '--direct-preflight-check-cert-readable', '--direct-preflight-check-service-env-file', '--direct-preflight-check-service-user-cert-readable', '--direct-preflight-check-service-binary-executable', ...(includeDirectPortsFree ? ['--direct-preflight-check-ports-free'] : []), ] : []), ]; } function buildSteps(config) { if (config.releaseRuntimeOnly) { return buildReleaseRuntimeSteps(config); } const steps = [ { name: 'Rust 路由与保护单测', command: 'cargo', args: [ 'test', '-p', 'pingora-gateway', '--manifest-path', 'server-rs/Cargo.toml', ], }, { name: 'Pingora mock 上游 smoke', command: 'npm', args: ['run', 'check:pingora-gateway-smoke'], }, { name: 'Nginx/Pingora 路由矩阵 parity', command: 'npm', args: ['run', 'check:pingora-route-parity'], }, { name: 'Nginx canary snippet 校验', command: 'node', args: [ 'scripts/check-nginx-pingora-canary.mjs', ...(config.requireNginx ? ['--require-nginx'] : []), ], }, { name: 'Docker Nginx handoff smoke', command: 'node', args: [ 'scripts/check-pingora-canary-docker.mjs', ...(config.requireDocker ? ['--require-docker'] : []), ...(config.pullDocker ? ['--pull'] : []), ], }, { name: 'Pingora canary access log 对账烟测', command: 'npm', args: ['run', 'check:pingora-canary-access-log-parity'], }, { name: 'Pingora realpath canary 启停烟测', command: 'npm', args: ['run', 'check:pingora-realpath-canary-toggle'], }, { name: 'Pingora canary live 参数护栏', command: 'node', args: ['scripts/check-pingora-canary-live-guard.mjs'], }, { name: 'Pingora direct entry 静态预检', command: 'npm', args: ['run', 'check:pingora-direct-preflight'], }, { name: 'Pingora direct live 参数护栏', command: 'node', args: ['scripts/check-pingora-direct-live-guard.mjs'], }, { name: 'Pingora direct rollback dry-run', command: 'npm', args: ['run', 'check:pingora-direct-rollback'], }, { name: 'Pingora direct enable dry-run', command: 'npm', args: ['run', 'check:pingora-direct-enable'], }, { name: 'Pingora release readiness plan', command: 'npm', args: ['run', 'check:pingora-release-readiness-plan'], }, { name: '生产运维护栏', command: 'npm', args: ['run', 'check:production-ops'], }, { name: '生产健康巡检烟测', command: 'npm', args: ['run', 'check:production-health-patrol'], }, { name: '生产健康巡检 env 复核烟测', command: 'npm', args: ['run', 'check:production-health-patrol-env'], }, { name: 'Pingora health patrol env 切换烟测', command: 'npm', args: ['run', 'check:pingora-health-patrol-env-switch'], }, { name: 'Pingora gateway env shadow 切换烟测', command: 'npm', args: ['run', 'check:pingora-gateway-env-shadow-switch'], }, { name: 'Pingora current release 自审烟测', command: 'npm', args: ['run', 'check:pingora-current-release-audit'], }, { name: 'Pingora direct rehearsal 状态烟测', command: 'npm', args: ['run', 'check:pingora-direct-rehearsal-status'], }, { name: 'Pingora cutover 状态快照烟测', command: 'npm', args: ['run', 'check:pingora-cutover-status-snapshot'], }, { name: 'Pingora cutover 证据包烟测', command: 'npm', args: ['run', 'check:pingora-cutover-evidence-bundle'], }, { name: 'Pingora cutover 命令证据烟测', command: 'npm', args: ['run', 'check:pingora-cutover-command-evidence'], }, { name: 'Pingora cutover 证据 manifest 验真烟测', command: 'npm', args: ['run', 'check:pingora-cutover-evidence-verify'], }, { name: 'Pingora cutover 证据根目录审计烟测', command: 'npm', args: ['run', 'check:pingora-cutover-evidence-audit'], }, { name: 'API release build 烟测', command: 'npm', args: ['run', 'check:production-api-release'], }, { name: 'Pingora production release 真实构建烟测', command: 'npm', args: ['run', 'check:pingora-production-release-build'], }, { name: 'API deploy release 烟测', command: 'npm', args: ['run', 'check:production-api-deploy'], }, ]; if (config.requireLive || config.liveBaseUrl) { appendTargetLiveSteps(steps, config, scriptPath); } appendTargetRealpathLiveSteps(steps, config, scriptPath); if (config.requireDirect || config.directHttpsBaseUrl) { steps.push({ name: '目标 Pingora direct entry preflight', command: 'node', args: [ '--', 'scripts/check-pingora-direct-preflight.mjs', ...(config.directPreflightEnvFile ? ['--env-file', config.directPreflightEnvFile, '--require-live-env'] : []), ...(config.directPreflightSystemd ? ['--systemd-cat'] : []), ...(config.directPreflightCheckCertReadable ? ['--check-cert-readable'] : []), ...(config.directPreflightCheckServiceEnvFile ? ['--check-service-env-file'] : []), ...(config.directPreflightCheckServiceUserCertReadable ? ['--check-service-user-cert-readable'] : []), ...(config.directPreflightCheckServiceBinaryExecutable ? ['--check-service-binary-executable'] : []), ...(config.directPreflightCheckPortsFree ? ['--check-ports-free'] : []), ], }); if (config.requireDirect || config.directHealthPatrolEnvFile) { steps.push({ name: '目标 health patrol env 直连模式复核', command: 'node', args: [ '--', 'scripts/check-production-health-patrol-env.mjs', '--env-file', config.directHealthPatrolEnvFile, '--expected-gateway-mode', 'pingora-direct', '--expected-public-base-url', config.directHttpsBaseUrl, '--expected-public-host', config.directHost, ], }); } steps.push({ name: '目标 Pingora direct live smoke', command: 'node', args: [ 'scripts/check-pingora-direct-live.mjs', '--https-base-url', config.directHttpsBaseUrl, ...(config.directHttpBaseUrl ? ['--http-base-url', config.directHttpBaseUrl] : []), ...(config.directHost ? ['--host', config.directHost] : []), ...(config.directRedirectHost ? ['--redirect-host', config.directRedirectHost] : []), ...(config.directRedirectBaseUrl ? ['--redirect-base-url', config.directRedirectBaseUrl] : []), ...(config.directProbeToken ? ['--probe-token', config.directProbeToken] : []), ...(config.directPingoraAccessLog ? [ '--pingora-access-log', config.directPingoraAccessLog, '--access-log-since-lines', config.directAccessLogSinceLines, ] : []), ...(config.directSpacetimeDatabase ? ['--spacetime-database', config.directSpacetimeDatabase] : []), ...(config.directRequireWssUpgrade ? ['--require-wss-upgrade'] : []), ...(config.directSkipWss ? ['--skip-wss'] : []), ...(config.directInsecureTls ? ['--insecure-tls'] : []), ], }); } return steps; } function buildReleaseRuntimeSteps(config) { const steps = [ { name: 'current release 自包含自审', command: 'node', args: [ '--', releaseScriptPath('scripts/ops/pingora-current-release-audit.mjs'), '--release-root', releaseRoot, ...(config.requireDirect ? ['--require-pingora-gateway'] : []), ...(config.requireDirect ? ['--systemd-show'] : []), ], cwd: releaseRoot, }, ]; if (!config.requireDirect) { steps.push({ name: '目标 Pingora direct rehearsal 状态复核', command: 'node', args: [ '--', releaseScriptPath('scripts/ops/pingora-direct-rehearsal-status.mjs'), '--release-root', releaseRoot, '--expect-public-gateway', 'nginx', '--require-pingora-shadow', '--require-realpath-canary', '--require-current-release-gateway', '--fail-on-critical', ], cwd: releaseRoot, }); } appendTargetLiveSteps(steps, config, releaseScriptPath); appendTargetRealpathLiveSteps(steps, config, releaseScriptPath); appendTargetDirectSteps(steps, config, releaseScriptPath); return steps; } function appendTargetLiveSteps(steps, config, scriptPath) { if (!config.requireLive && !config.liveBaseUrl) { return; } steps.push({ name: '目标 Nginx live canary smoke', command: 'node', args: [ '--', scriptPath('scripts/check-pingora-canary-live.mjs'), '--base-url', config.liveBaseUrl, ...(config.liveHost ? ['--host', config.liveHost] : []), ], cwd: releaseRoot, }); steps.push({ name: '目标 Nginx live canary access log 对账', command: 'node', args: [ '--', scriptPath('scripts/check-pingora-canary-access-log-parity.mjs'), '--nginx-log-file', config.liveAccessLogNginx, '--pingora-log-file', config.liveAccessLogPingora, '--since-lines', String(config.liveAccessLogSinceLines), '--path', '/__genarrative_pingora_canary/healthz', '--path', '/__genarrative_pingora_canary/api/assets/history', ], cwd: releaseRoot, }); } function appendTargetRealpathLiveSteps(steps, config, scriptPath) { if (!config.requireRealpathLive && !config.realpathLiveBaseUrl) { return; } steps.push({ name: '目标 Nginx realpath canary smoke', command: 'node', args: [ '--', scriptPath('scripts/check-pingora-canary-live.mjs'), '--realpath', '--base-url', config.realpathLiveBaseUrl, ...(config.realpathLiveHost ? ['--host', config.realpathLiveHost] : []), ], cwd: releaseRoot, }); steps.push({ name: '目标 Nginx realpath canary access log 对账', command: 'node', args: [ '--', scriptPath('scripts/check-pingora-canary-access-log-parity.mjs'), '--realpath', '--nginx-log-file', config.realpathLiveAccessLogNginx, '--pingora-log-file', config.realpathLiveAccessLogPingora, '--since-lines', String(config.realpathLiveAccessLogSinceLines), '--path', '/__genarrative_pingora_realpath_canary/healthz', '--path', '/api/assets/history', '--path', '/v1/identity', '--path', '/assets/app.js', ], cwd: releaseRoot, }); } function appendTargetDirectSteps(steps, config, scriptPath) { if (!config.requireDirect && !config.directHttpsBaseUrl) { return; } steps.push({ name: '目标 Pingora direct entry preflight', command: 'node', args: [ '--', scriptPath('scripts/check-pingora-direct-preflight.mjs'), ...(config.directPreflightEnvFile ? ['--env-file', config.directPreflightEnvFile, '--require-live-env'] : []), ...(config.directPreflightSystemd ? ['--systemd-cat'] : []), ...(config.directPreflightCheckCertReadable ? ['--check-cert-readable'] : []), ...(config.directPreflightCheckServiceEnvFile ? ['--check-service-env-file'] : []), ...(config.directPreflightCheckServiceUserCertReadable ? ['--check-service-user-cert-readable'] : []), ...(config.directPreflightCheckServiceBinaryExecutable ? ['--check-service-binary-executable'] : []), ...(config.directPreflightCheckPortsFree ? ['--check-ports-free'] : []), ], cwd: releaseRoot, }); if (config.requireDirect || config.directHealthPatrolEnvFile) { steps.push({ name: '目标 health patrol env 直连模式复核', command: 'node', args: [ '--', scriptPath('scripts/check-production-health-patrol-env.mjs'), '--env-file', config.directHealthPatrolEnvFile, '--expected-gateway-mode', 'pingora-direct', '--expected-public-base-url', config.directHttpsBaseUrl, '--expected-public-host', config.directHost, ], cwd: releaseRoot, }); } steps.push({ name: '目标 Pingora direct live smoke', command: 'node', args: [ '--', scriptPath('scripts/check-pingora-direct-live.mjs'), '--https-base-url', config.directHttpsBaseUrl, ...(config.directHttpBaseUrl ? ['--http-base-url', config.directHttpBaseUrl] : []), ...(config.directHost ? ['--host', config.directHost] : []), ...(config.directRedirectHost ? ['--redirect-host', config.directRedirectHost] : []), ...(config.directRedirectBaseUrl ? ['--redirect-base-url', config.directRedirectBaseUrl] : []), ...(config.directProbeToken ? ['--probe-token', config.directProbeToken] : []), ...(config.directPingoraAccessLog ? [ '--pingora-access-log', config.directPingoraAccessLog, '--access-log-since-lines', config.directAccessLogSinceLines, ] : []), ...(config.directSpacetimeDatabase ? ['--spacetime-database', config.directSpacetimeDatabase] : []), ...(config.directRequireWssUpgrade ? ['--require-wss-upgrade'] : []), ...(config.directSkipWss ? ['--skip-wss'] : []), ...(config.directInsecureTls ? ['--insecure-tls'] : []), ], cwd: releaseRoot, }); } function releaseScriptPath(relativePath) { return path.join(releaseRoot, relativePath); } function scriptPath(relativePath) { return relativePath; } function runStep(step) { const args = commandArgsForSpawn(step); console.log(`\n[pingora-release-readiness] ${step.name}`); console.log( `[pingora-release-readiness] ${step.command} ${redactSecretArgs(args).join(' ')}`, ); return new Promise((resolve) => { const child = spawn(step.command, args, { cwd: step.cwd || repoRoot, env: smokeEnv(), shell: false, stdio: 'inherit', }); child.on('error', (error) => { failures.push(`${step.name}: ${step.command} 启动失败:${error.message}`); resolve(false); }); child.on('exit', (status, signal) => { if (signal) { failures.push(`${step.name}: 被信号终止:${signal}`); resolve(false); return; } if ((status ?? 0) !== 0) { failures.push(`${step.name}: 退出码 ${status}`); resolve(false); return; } resolve(true); }); }); } function commandArgsForSpawn(step) { if (step.command === 'node' && step.args[0] !== '--') { return ['--', ...step.args]; } return step.args; } function smokeEnv() { return { ...process.env, PATH: `${path.join(os.homedir(), '.local', 'bin')}:${process.env.PATH || ''}`, }; }