import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; const root = process.cwd(); function read(relativePath) { return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n'); } function section(source, startMarker, endMarker) { const start = source.indexOf(startMarker); assert.notEqual(start, -1, `missing section start: ${startMarker}`); const end = source.indexOf(endMarker, start + startMarker.length); assert.notEqual(end, -1, `missing section end: ${endMarker}`); return source.slice(start, end); } function ordered(source, markers, label) { let cursor = 0; for (const marker of markers) { const index = source.indexOf(marker, cursor); assert.notEqual(index, -1, `${label}: missing or out-of-order marker: ${marker}`); cursor = index + marker.length; } } const rootDeploy = read('deploy.sh'); const compatDeploy = read('scripts/deploy/bin/deploy.sh'); const rootEnv = read('deploy.env.example'); const compatEnv = read('scripts/deploy/env/deploy.env.example'); const packageJson = read('package.json'); const taroPackageJson = read('apps/taro/package.json'); const apiService = read('scripts/deploy/systemd/tiku-api.service'); const workerService = read('scripts/deploy/systemd/tiku-worker@.service'); const workerTarget = read('scripts/deploy/systemd/tiku-workers.target'); const apiEnv = read('scripts/deploy/env/api.env.example'); const workerEnv = read('scripts/deploy/env/worker.env.example'); const runtimeRoleBootstrap = read('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql'); const deployReadme = read('scripts/deploy/README.md'); assert.match(rootDeploy, /: "\$\{WWW_ROOT:=\/srv\/tiku-saas\/www\}"/); assert.match(rootDeploy, /: "\$\{SERVICE_REPO_DIR:=\/opt\/tiku-saas\/repo\}"/); assert.match(rootDeploy, /: "\$\{SERVICE_MODE:=systemd\}"/); assert.match(rootDeploy, /: "\$\{HEALTHCHECK_URL:=http:\/\/127\.0\.0\.1:8787\/health\}"/); assert.match(rootDeploy, /Production deployment requires a service restart strategy/); assert.match(rootDeploy, /Production deployment requires HEALTHCHECK_URL/); assert.match(rootDeploy, /Production systemd deployment requires SYNC_SERVICE_REPO=true/); assert.match(rootDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/); assert.match(compatDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/); assert.match(rootDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/); assert.match(compatDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/); assert.match(rootDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/); assert.match(compatDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/); assert.match(rootDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/); assert.match(compatDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/); assert.match(compatDeploy, /source "\$API_ENV_FILE"/); assert.ok(rootDeploy.includes('LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"')); assert.ok(compatDeploy.includes('LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"')); assert.match(rootDeploy, /LOCK_ACQUIRED=false/); assert.match(compatDeploy, /LOCK_ACQUIRED=false/); assert.match(compatDeploy, /export GIT_TERMINAL_PROMPT=0/); assert.match(rootDeploy, /NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev/); assert.match(rootDeploy, /NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches/); assert.doesNotMatch(rootEnv, /--ignore-scripts/); assert.match(rootEnv, /^NPM_INSTALL_COMMAND="npm ci --workspaces --include-workspace-root --include=dev"$/m); assert.match(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--workspaces[\s\S]*--include-workspace-root[\s\S]*--include=dev/); assert.doesNotMatch(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--ignore-scripts/); assert.match(taroPackageJson, /"postinstall": "node \.\.\/\.\.\/scripts\/taro-components-h5-runtime-patch\.js --apply"/); assert.match(apiService, /WorkingDirectory=\/opt\/tiku-saas\/repo/); assert.match(workerService, /WorkingDirectory=\/opt\/tiku-saas\/repo/); assert.match(workerService, /ExecStart=.*--loop --job %i/); assert.match(workerTarget, /Requires=tiku-worker@crm\.service/); assert.match(workerTarget, /Wants=tiku-worker-monthly-usage\.timer/); for (const [dist, target] of [ ['h5-student', 'student'], ['h5-tenant-admin', 'tenant-admin'], ['h5-platform-admin', 'platform-admin'], ]) { assert.ok( rootDeploy.includes(`$release/apps/taro/dist/${dist}/\" \"$staging/${target}/`), `root deploy must stage ${dist} as ${target}`, ); assert.ok( compatDeploy.includes(`$CANDIDATE_RELEASE/apps/taro/dist/${dist}/\" \"$staging/${target}/`), `compat deploy must stage ${dist} as ${target}`, ); } const rootMain = section(rootDeploy, 'main() {', '\n}\n\nmain "$@"'); ordered(rootMain, [ 'run_build_and_checks "$NEW_RELEASE"', 'stage_h5_release "$NEW_RELEASE"', 'ROLLBACK_ARMED=true', 'switch_current "$NEW_RELEASE"', 'sync_service_repo "$NEW_RELEASE"', 'restart_services', 'healthcheck', 'switch_www_release', 'verify_live_h5_release "$NEW_RELEASE"', ], 'root activation order'); assert.ok(rootDeploy.includes('DEPLOY_RELEASE_ROOT="$release"'), 'root launch gate must bind the candidate release root'); assert.ok(rootDeploy.includes('--verify-live-h5'), 'root deploy must verify the activated production H5 release'); const candidateChecks = section(compatDeploy, 'build_and_validate_candidate() {', '\n}\n\nstage_www_candidate() {'); ordered(candidateChecks, [ 'npm run audit:taro:supply-chain', 'npm run build:taro:h5:student', 'npm run build:taro:h5:platform', 'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json"', 'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json"', 'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json"', 'node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config', 'npm run manifest:taro:h5 -- --require-dist --require-runtime-config', 'npm run smoke:taro:h5', 'npm run audit:runtime', 'load_runtime_env', 'npm run readiness:production', 'run_shell "$DB_MIGRATION_COMMAND"', 'npm run readiness:production:db', 'npm run launch:gate', ], 'compat candidate validation order'); assert.ok(compatDeploy.includes('DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE"'), 'compat launch gate must bind the candidate release root'); const rootChecks = section(rootDeploy, 'run_build_and_checks() {', '\n}\n\nverify_live_h5_release() {'); ordered(rootChecks, [ 'npm run audit:taro:supply-chain', 'npm run build:taro:h5:student', 'npm run audit:runtime', 'link_shared_env "$release"', 'load_runtime_env', 'npm run readiness:production', 'run_shell "$DB_MIGRATION_COMMAND"', 'npm run readiness:production:db', 'npm run launch:gate', ], 'root database gate order'); const compatMain = section(compatDeploy, 'main() {', '\n}\n\nmain "$@"'); assert.doesNotMatch( compatMain, /load_runtime_env/, 'production secrets must not be loaded before dependency installation and candidate builds', ); ordered(compatMain, [ 'stage_candidate "$release_name"', 'build_and_validate_candidate', 'stage_www_candidate "$release_name"', 'ROLLBACK_ARMED=true', 'atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK"', 'sync_service_repo "$CANDIDATE_RELEASE"', 'restart_services', 'healthcheck', 'switch_www_release "$candidate_www"', 'verify_live_h5_release', ], 'compat activation order'); assert.ok(compatDeploy.includes('--verify-live-h5'), 'compat deploy must verify the activated production H5 release'); assert.ok(!compatDeploy.includes('"$WWW_ROOT/student/"'), 'compat deploy must not overwrite the live student directory'); assert.match(compatDeploy, /WWW_RELEASES_DIR:\=\$\{WWW_ROOT%\/\}-releases/); assert.match(compatDeploy, /\.tmp-\$release_name/); const compatRollback = section(compatDeploy, 'rollback() {', '\n}\n\ncleanup() {'); assert.match(compatRollback, /atomic_symlink "\$PREVIOUS_WWW_RELEASE" "\$WWW_ROOT"/); assert.match(compatRollback, /sync_service_repo "\$PREVIOUS_APP_RELEASE"/); assert.match(compatRollback, /restart_services/); assert.match(rootEnv, /^WWW_ROOT=\/srv\/tiku-saas\/www$/m); assert.match(rootEnv, /^SERVICE_REPO_DIR=\/opt\/tiku-saas\/repo$/m); assert.match(rootEnv, /^SERVICE_MODE=systemd$/m); assert.match(rootEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m); assert.match(rootEnv, /^HEALTHCHECK_URL=http:\/\/127\.0\.0\.1:8787\/health$/m); assert.match(compatEnv, /^SOURCE_REPO_DIR=\/opt\/tiku-saas\/source$/m); assert.match(compatEnv, /^REPO_DIR=\/opt\/tiku-saas\/repo$/m); assert.match(rootEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m); assert.match(compatEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m); assert.match(rootEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m); assert.match(compatEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m); assert.match(compatEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m); assert.match(workerEnv, /^STORAGE_DEFAULT_BUCKET=/m); assert.match(workerEnv, /^WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=/m); assert.match(workerEnv, /^WORKER_CRM_POLL_INTERVAL_MS=/m); assert.match(apiEnv, /chown root:deploy, and chmod 640/); assert.doesNotMatch(workerEnv, /^ALIYUN_OSS_BUCKET=/m); assert.doesNotMatch(workerEnv, /^ASSET_SECURITY_SCAN_ENDPOINT=/m); assert.doesNotMatch(workerEnv, /^WORKER_POLL_INTERVAL_MS=/m); assert.match(runtimeRoleBootstrap, /alter role tiku_api[\s\S]*bypassrls/i); assert.match(runtimeRoleBootstrap, /alter role tiku_worker[\s\S]*bypassrls/i); assert.doesNotMatch(runtimeRoleBootstrap, /password\s+['"]/i); assert.match(deployReadme, /bootstrap:db-runtime-roles/); assert.match(deployReadme, /BOOTSTRAP_BACKEND_RUNTIME_ROLES/); assert.ok( packageJson.includes('npm audit --registry=${NPM_AUDIT_REGISTRY:-https://registry.npmjs.org/}'), 'npm audit scripts must default to the official audit registry while remaining configurable', ); console.log('deploy contract: ok');