Files
gongxue-base/scripts/deploy-contract-test.js
2026-07-12 19:26:57 +08:00

197 lines
10 KiB
JavaScript

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');