Files
Genarrative/scripts/check-preview-deployer.mjs
T
kdletters 7f60a00c84
Project CI / Repository checks (push) Successful in 1m23s
Project CI / Frontend tests (push) Successful in 2m59s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 14m7s
修复预览镜像认证入口
为分支预览启用首次密码登录自动注册

为预览短信入口配置隔离 mock 验证码

补充预览部署门禁和认证验收文档
2026-08-17 22:55:27 +08:00

259 lines
7.2 KiB
JavaScript

import { readFileSync } from 'node:fs';
const jenkinsfile = readFileSync(
'jenkins/Jenkinsfile.preview-deployer',
'utf8',
);
const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8');
const resultWriter = readFileSync(
'scripts/preview-deployment-status.mjs',
'utf8',
);
const compose = readFileSync(
'deploy/container/docker-compose.loadtest.yml',
'utf8',
);
const webConfig = readFileSync(
'apps/preview-deployer-web/vite.config.ts',
'utf8',
);
const systemd = readFileSync(
'deploy/systemd/genarrative-preview-deployer.service',
'utf8',
);
const environmentExample = readFileSync(
'deploy/env/preview-deployer.env.example',
'utf8',
);
const server = readFileSync(
'server-rs/crates/preview-deployer-server/src/lib.rs',
'utf8',
);
const nginx = readFileSync(
'deploy/nginx/genarrative-preview-deployer-lan.conf',
'utf8',
);
const jobConfig = readFileSync(
'jenkins/preview-deployer-job-config.xml',
'utf8',
);
const failures = [];
assertIncludes(
jenkinsfile,
"choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL']",
'Jenkins 参数必须固定为 DEPLOY/STATUS/UNINSTALL。',
);
assertIncludes(
jenkinsfile,
"string(name: 'SOURCE_BRANCH'",
'Jenkins 必须暴露 SOURCE_BRANCH。',
);
assertIncludes(
jenkinsfile,
"string(name: 'COMMIT_HASH'",
'Jenkins 必须暴露 COMMIT_HASH。',
);
assertIncludes(
jenkinsfile,
"string(name: 'DEPLOYMENT_ID'",
'Jenkins 必须暴露 DEPLOYMENT_ID。',
);
assertIncludes(
jenkinsfile,
"archiveArtifacts artifacts: 'preview-result.json'",
'Jenkins 必须归档 preview-result.json。',
);
assertIncludes(
jenkinsfile,
"writeFile file: 'preview-result.json'",
'Jenkins 失败路径也必须生成 preview-result.json。',
);
assertIncludes(
jenkinsfile,
'disableConcurrentBuilds()',
'Jenkins Job 必须禁止并发构建。',
);
assertIncludes(
jenkinsfile,
'COMMIT_HASH="${COMMIT_HASH:-}"',
'Jenkins Checkout 阶段必须允许 COMMIT_HASH 未导出或为空。',
);
assertIncludes(
jenkinsfile,
'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"',
'Jenkins Checkout 阶段必须显式使用 bash 执行源码检出脚本。',
);
assertExcludes(
jenkinsfile,
'COMMIT_HASH="${COMMIT_HASH}"',
'Jenkins Checkout 阶段不得在 set -u 下直接展开未导出的 COMMIT_HASH。',
);
assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。');
assertIncludes(
deployer,
'git check-ref-format --branch',
'部署脚本必须校验分支格式。',
);
assertIncludes(
deployer,
'genarrative-preview-[0-9a-f]{16}',
'卸载必须限制到预览 compose project 白名单。',
);
assertIncludes(
deployer,
'allocate_port webPort 8400 8499',
'Web 端口必须从 8400-8499 分配。',
);
assertIncludes(
deployer,
'ports: !reset []',
'预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。',
);
assertIncludes(
deployer,
'spacetimedb:\n mem_limit: 2g\n ports: !reset []',
'预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。',
);
assertIncludes(
deployer,
'external-generation-worker:\n build:',
'预览 compose override 必须配置外部生成 worker。',
);
assertIncludes(
deployer,
'restart: on-failure',
'预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。',
);
assertIncludes(
deployer,
'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true',
'预览独立空库必须允许首次密码登录自动创建预览账号。',
);
assertIncludes(
deployer,
"'SMS_AUTH_ENABLED=true',\n 'SMS_AUTH_PROVIDER=mock',\n 'SMS_AUTH_MOCK_VERIFY_CODE=123456'",
'预览短信登录必须使用固定 mock 验证码,不得继承生产短信密钥。',
);
assertIncludes(
deployer,
'^preview-[0-9a-f]{16}$',
'部署 ID 必须固定为 preview- 加 16 位摘要。',
);
assertIncludes(
deployer,
'state.active === true && String(state[key]) === expected',
'只有活动实例才能占用端口租约。',
);
assertIncludes(
deployer,
'--yes=remote,migrate,break-clients',
'SpacetimeDB 预览发布必须显式确认受控容器网络目标,且不得默认删除实例数据。',
);
assertExcludes(
deployer,
'--delete-data',
'预览部署不得使用 SpacetimeDB 删除数据参数。',
);
assertIncludes(
jenkinsfile,
"GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'",
'Jenkins 必须固定输出可供同事访问的内网 Web 主机地址。',
);
assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。');
assertIncludes(
systemd,
'User=jenkins',
'控制服务必须使用 Jenkins 运行用户以访问受控构建状态。',
);
assertIncludes(
nginx,
'location ^~ /build/',
'内网 Nginx 必须提供 /build/ 路由。',
);
assertIncludes(
server,
'.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))',
'控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。',
);
assertIncludes(
server,
'FAILED_RECORD_TTL_SECS',
'控制服务必须清理过期且不可卸载的失败/取消记录。',
);
assertIncludes(
server,
'STOPPED_RECORD_TTL_SECS',
'控制服务必须为已卸载记录配置有限审计保留期。',
);
assertIncludes(
environmentExample,
'GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/',
'构建详情必须使用局域网 Jenkins 地址而非 loopback。',
);
assertIncludes(
jobConfig,
'<scriptPath>jenkins/Jenkinsfile.preview-deployer</scriptPath>',
'Jenkins Job 必须从受保护 master 读取固定流水线。',
);
assertIncludes(
jobConfig,
'<hudson.model.ChoiceParameterDefinition>',
'Jenkins Job 首次触发前必须已经声明 buildWithParameters 参数。',
);
assertIncludes(
nginx,
'location ^~ /api/preview-deployer/',
'内网 Nginx 必须代理预览控制 API。',
);
assertIncludes(
resultWriter,
'schemaVersion: 1',
'结果文件必须带版本化 schema。',
);
assertIncludes(resultWriter, 'healthStatus', '结果文件必须提供健康状态。');
assertIncludes(resultWriter, 'webUrl', '结果文件必须提供 Web URL。');
assertIncludes(
resultWriter,
'resolvedCommit',
'结果文件必须提供 resolvedCommit。',
);
assertIncludes(
resultWriter,
'status: publicStatus(phase)',
'结果文件必须提供小写 status 契约。',
);
assertIncludes(
resultWriter,
'health: publicHealth(healthStatus)',
'结果文件必须提供小写 health 契约。',
);
assertIncludes(
compose,
'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]',
'Nginx 容器健康检查必须验证 Web 首页。',
);
assertExcludes(
compose,
'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]',
'Nginx 容器健康检查不能访问公开拒绝的 /healthz。',
);
if (failures.length > 0) {
for (const failure of failures)
console.error(`[check:preview-deployer] ${failure}`);
process.exit(1);
}
console.log(
'[check:preview-deployer] Jenkins 参数、隔离部署、卸载白名单、结果契约和 Nginx 健康检查通过。',
);
function assertIncludes(content, expected, message) {
if (!content.includes(expected)) failures.push(message);
}
function assertExcludes(content, expected, message) {
if (content.includes(expected)) failures.push(message);
}