From ced371309cda3ab58d9d16aac9988dd2839b8ad1 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 05:27:18 +0800 Subject: [PATCH] test: add taro h5 release manifest gate --- README.md | 4 +- apps/taro/README.md | 3 +- .../production-launch-evidence.template.json | 14 + docs/refactor/taro-h5-deployment.md | 4 +- package.json | 3 +- scripts/production-launch-gate-test.js | 11 + scripts/production-launch-gate.js | 12 + scripts/taro-h5-release-manifest-test.js | 108 +++++ scripts/taro-h5-release-manifest.js | 402 ++++++++++++++++++ 9 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 scripts/taro-h5-release-manifest-test.js create mode 100644 scripts/taro-h5-release-manifest.js diff --git a/README.md b/README.md index 2f7257ce..f2d5b9da 100644 --- a/README.md +++ b/README.md @@ -614,10 +614,11 @@ npm run build:taro:h5:tenant npm run build:taro:h5:platform npm run smoke:taro:h5 npm run smoke:taro:h5:interaction +npm run manifest:taro:h5 node scripts\taro-h5-release-guardrails-test.js --require-dist ``` -`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge,打开三套 H5 产物并点击 26 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台六个主模块,以及平台后台四个主模块,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。 +`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge,打开三套 H5 产物并点击 26 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台六个主模块,以及平台后台四个主模块,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`manifest:taro:h5` 会生成三套 H5 的部署清单,记录构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 状态和租户解析模式,方便前端/运维核对实际上传目录。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。 写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0: @@ -625,6 +626,7 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json +npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json ``` 最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、196,846 条答题记录、38,207 条错题和 466 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。 diff --git a/apps/taro/README.md b/apps/taro/README.md index 5fc14a3c..02033bb2 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -25,10 +25,11 @@ H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.h ```bash npm run smoke:taro:h5 +npm run manifest:taro:h5 node scripts/taro-h5-release-guardrails-test.js --require-dist ``` -`smoke:taro:h5` 会用临时静态服务器验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 `runtime-config.json` 和 `/api/tenant/resolve` 契约。发布守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json`。 +`smoke:taro:h5` 会用临时静态服务器验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 `runtime-config.json` 和 `/api/tenant/resolve` 契约。`manifest:taro:h5` 会输出三套 H5 的部署清单,包含构建命令、发布目录、入口路由、资源数量、`index.html` hash 和 runtime-config 状态,方便上线前核对 Web 根目录。发布守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json`。 ## 当前学生端页面 diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index 0418637d..6c366867 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -286,6 +286,20 @@ "warn": 0 } }, + { + "id": "taro.h5-release-manifest", + "status": "pass", + "command": "npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json", + "completedAt": "2026-06-30T12:16:00+08:00", + "artifact": "launch-artifacts/taro-h5-release-manifest.json", + "summary": { + "fail": 0, + "warn": 0, + "portals": 3, + "distReady": 3, + "runtimeConfigs": 3 + } + }, { "id": "audit.runtime", "status": "pass", diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index e02bd780..440ef1f9 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -193,10 +193,11 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 ```bash npm run smoke:taro:h5 npm run smoke:taro:h5:interaction + npm run manifest:taro:h5 node scripts/taro-h5-release-guardrails-test.js --require-dist ``` - `smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。`smoke:taro:h5:interaction` 会用真实 Chrome/Edge 打开三套发布产物并点击学生刷题/收藏/会员、租户题库内容/财务、平台租户/账务中心关键路径;若服务器没有默认浏览器,可设置 `TARO_H5_SMOKE_BROWSER=/path/to/chrome`。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。 + `smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。`smoke:taro:h5:interaction` 会用真实 Chrome/Edge 打开三套发布产物并点击学生刷题/收藏/会员、租户题库内容/财务、平台租户/账务中心关键路径;若服务器没有默认浏览器,可设置 `TARO_H5_SMOKE_BROWSER=/path/to/chrome`。`manifest:taro:h5` 会生成三套 H5 的部署清单,包含构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 是否存在、租户解析模式和公开配置状态。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。 写入 `production-launch-evidence.json` 的正式证据必须使用严格模式,确保三套发布目录都已放置真实公开 `runtime-config.json` 且没有 warning: @@ -204,6 +205,7 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json + npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json ``` 7. 收集生产上线证据并运行 launch gate: diff --git a/package.json b/package.json index ceea9945..ea3e67ca 100644 --- a/package.json +++ b/package.json @@ -58,12 +58,13 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", "smoke:taro:h5": "node scripts/taro-h5-static-smoke.js", "smoke:taro:h5:interaction": "node scripts/taro-h5-interaction-smoke.js", + "manifest:taro:h5": "node scripts/taro-h5-release-manifest.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js", "test:perf:summary": "node scripts/performance-summary-test.js", diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js index 246fa289..16fe4350 100644 --- a/scripts/production-launch-gate-test.js +++ b/scripts/production-launch-gate-test.js @@ -209,6 +209,17 @@ assert.ok( 'missing H5 interaction smoke should be reported as a blocker', ); +const missingTaroReleaseManifest = runGate(tempDir => { + const evidence = createEvidence(tempDir); + evidence.checks = evidence.checks.filter(item => item.id !== 'taro.h5-release-manifest'); + return evidence; +}); +assert.notEqual(missingTaroReleaseManifest.status, 0, 'missing H5 release manifest should fail launch gate'); +assert.ok( + missingTaroReleaseManifest.payload.checks?.some(item => item.id === 'check.taro.h5-release-manifest' && item.status === 'blocker'), + 'missing H5 release manifest should be reported as a blocker', +); + const missingRepoSecurityScan = runGate(tempDir => { const evidence = createEvidence(tempDir); evidence.checks = evidence.checks.filter(item => item.id !== 'security.repo-scan'); diff --git a/scripts/production-launch-gate.js b/scripts/production-launch-gate.js index 71c0c725..9aed7774 100644 --- a/scripts/production-launch-gate.js +++ b/scripts/production-launch-gate.js @@ -183,6 +183,18 @@ const gateChecks = [ warn: 0, }, }, + { + id: 'taro.h5-release-manifest', + label: 'Taro H5 release deployment manifest', + commandIncludes: 'manifest:taro:h5', + summary: { + fail: 0, + warn: 0, + portals: 3, + distReady: 3, + runtimeConfigs: 3, + }, + }, { id: 'audit.runtime', label: 'Runtime dependency audit', diff --git a/scripts/taro-h5-release-manifest-test.js b/scripts/taro-h5-release-manifest-test.js new file mode 100644 index 00000000..404fdd66 --- /dev/null +++ b/scripts/taro-h5-release-manifest-test.js @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'taro-h5-release-manifest.js'); +const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist'); + +function run(args = []) { + return spawnSync(process.execPath, [scriptPath, '--json', ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { + PATH: process.env.PATH || '', + Path: process.env.Path || '', + SystemRoot: process.env.SystemRoot || '', + ComSpec: process.env.ComSpec || '', + TEMP: process.env.TEMP || os.tmpdir(), + TMP: process.env.TMP || os.tmpdir(), + }, + }); +} + +function parseJson(result) { + return JSON.parse(result.stdout || '{}'); +} + +function withRuntimeConfig(portal, config, fn) { + const filePath = path.join(distRoot, portal, 'runtime-config.json'); + const existed = fs.existsSync(filePath); + const previous = existed ? fs.readFileSync(filePath, 'utf8') : ''; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + try { + return fn(filePath); + } finally { + if (existed) fs.writeFileSync(filePath, previous, 'utf8'); + else fs.rmSync(filePath, { force: true }); + } +} + +const loose = run(); +assert.equal(loose.status, 0, loose.stderr); +const loosePayload = parseJson(loose); +assert.equal(loosePayload.schemaVersion, 1); +assert.equal(loosePayload.summary.portals, 3); +assert.equal(loosePayload.portals.length, 3); +assert.ok(loosePayload.portals.every(item => item.buildCommand.startsWith('npm run build:taro:h5:'))); +assert.ok(loosePayload.checks.some(item => item.id === 'build.student.portal_env' && item.status === 'pass')); + +const strictMissingRuntime = run(['--require-runtime-config']); +assert.notEqual(strictMissingRuntime.status, 0, 'strict manifest should fail when runtime-config files are missing'); +const strictPayload = parseJson(strictMissingRuntime); +assert.ok( + strictPayload.checks.some(item => item.id === 'runtime.student.exists' && item.status === 'fail'), + 'missing student runtime config should be a failure', +); + +const validConfig = { + portal: 'student', + apiBaseUrl: 'https://api.example.com', + supabaseUrl: 'https://auth.example.com', + supabasePublishableKey: 'sb_publishable_test_key', + tenantCode: '', +}; + +withRuntimeConfig('h5-student', validConfig, () => { + const withStudentRuntime = run(); + assert.equal(withStudentRuntime.status, 0, withStudentRuntime.stderr); + const payload = parseJson(withStudentRuntime); + const student = payload.portals.find(item => item.portal === 'student'); + assert.equal(student.runtimeConfig.exists, true); + assert.equal(student.runtimeConfig.tenantCodeMode, 'domain-resolved'); + assert.ok(payload.checks.some(item => item.id === 'runtime.student.api_https' && item.status === 'pass')); +}); + +withRuntimeConfig('h5-student', { + ...validConfig, + supabasePublishableKey: 'replace-with-supabase-publishable-key', +}, () => { + const placeholder = run(); + assert.notEqual(placeholder.status, 0, 'production manifest should reject placeholder publishable key in dist runtime config'); + const payload = parseJson(placeholder); + assert.ok(payload.checks.some(item => item.id === 'runtime.student.publishable_key' && item.status === 'fail')); +}); + +withRuntimeConfig('h5-student', { + ...validConfig, + DATABASE_URL: `postgres${'ql'}://postgres:postgres@example.com/postgres`, +}, () => { + const leakedSecret = run(); + assert.notEqual(leakedSecret.status, 0, 'manifest should reject forbidden runtime config keys'); + const payload = parseJson(leakedSecret); + assert.ok(payload.checks.some(item => item.id === 'runtime.student.keys' && item.status === 'fail')); + assert.ok(payload.checks.some(item => item.id === 'runtime.student.secrets' && item.status === 'fail')); +}); + +const outputPath = path.join(os.tmpdir(), `taro-release-manifest-${Date.now()}.json`); +const written = run(['--write', outputPath]); +assert.equal(written.status, 0, written.stderr); +assert.equal(fs.existsSync(outputPath), true); +const writtenPayload = JSON.parse(fs.readFileSync(outputPath, 'utf8')); +assert.equal(writtenPayload.schemaVersion, 1); +fs.rmSync(outputPath, { force: true }); + +console.log('[PASS] Taro H5 release manifest'); diff --git a/scripts/taro-h5-release-manifest.js b/scripts/taro-h5-release-manifest.js new file mode 100644 index 00000000..4560d86f --- /dev/null +++ b/scripts/taro-h5-release-manifest.js @@ -0,0 +1,402 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const repoRoot = process.cwd(); +const taroRoot = path.join(repoRoot, 'apps', 'taro'); +const deployRoot = path.join(taroRoot, 'deploy'); +const distRoot = path.join(taroRoot, 'dist'); + +const portals = [ + { + portal: 'student', + buildScript: 'build:taro:h5:student', + appBuildScript: 'build:h5:student', + dist: 'h5-student', + landingPath: '/pages/student/home/index', + runtimeExample: 'h5-student.runtime-config.example.json', + deployHint: 'student.example.com -> apps/taro/dist/h5-student', + }, + { + portal: 'tenant-admin', + buildScript: 'build:taro:h5:tenant', + appBuildScript: 'build:h5:tenant', + dist: 'h5-tenant-admin', + landingPath: '/pages/tenant-admin/workbench/index', + runtimeExample: 'h5-tenant-admin.runtime-config.example.json', + deployHint: 'admin.example.com -> apps/taro/dist/h5-tenant-admin', + }, + { + portal: 'platform-admin', + buildScript: 'build:taro:h5:platform', + appBuildScript: 'build:h5:platform', + dist: 'h5-platform-admin', + landingPath: '/pages/platform-admin/workbench/index', + runtimeExample: 'h5-platform-admin.runtime-config.example.json', + deployHint: 'console.example.com -> apps/taro/dist/h5-platform-admin', + }, +]; + +const allowedRuntimeConfigKeys = new Set([ + 'portal', + 'apiBaseUrl', + 'supabaseUrl', + 'supabasePublishableKey', + 'tenantCode', + 'TARO_APP_PORTAL', + 'TARO_APP_API_BASE_URL', + 'TARO_APP_SUPABASE_URL', + 'TARO_APP_SUPABASE_PUBLISHABLE_KEY', + 'TARO_APP_TENANT_CODE', +]); + +const forbiddenRuntimeConfigKeys = new Set([ + 'SUPABASE_SERVICE_ROLE_KEY', + 'SUPABASE_SECRET_KEY', + 'DATABASE_URL', + 'ALIYUN_OSS_ACCESS_KEY_SECRET', + 'TENCENT_COS_SECRET_KEY', + 'WECHAT_PAY_PRIVATE_KEY', + 'ALIPAY_APP_PRIVATE_KEY', + 'AUTH_SESSION_SECRET', + 'PLATFORM_ADMIN_API_KEY', +]); + +const forbiddenValuePatterns = [ + /postgres(?:ql)?:\/\//i, + /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, + /\bAKIA[0-9A-Z]{16}\b/, + /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, + /\b(service_role|secret|private_key)\b/i, +]; + +function parseArgs(argv) { + const options = { + json: false, + requireDist: false, + requireRuntimeConfig: false, + writePath: '', + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (arg === '--require-dist') options.requireDist = true; + else if (arg === '--require-runtime-config') { + options.requireRuntimeConfig = true; + options.requireDist = true; + } else if (arg === '--write') { + options.writePath = path.resolve(repoRoot, argv[index + 1] || ''); + index += 1; + } else if (arg.startsWith('--write=')) { + options.writePath = path.resolve(repoRoot, arg.slice('--write='.length)); + } + } + return options; +} + +function normalizeSlashes(value) { + return value.replace(/\\/g, '/'); +} + +function relative(filePath) { + return normalizeSlashes(path.relative(repoRoot, filePath)); +} + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +} + +function readJson(filePath) { + return JSON.parse(readText(filePath)); +} + +function walkFiles(dir) { + if (!fs.existsSync(dir)) return []; + const result = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) result.push(...walkFiles(entryPath)); + else result.push(entryPath); + } + return result; +} + +function parseAssetUrls(indexHtml) { + const urls = new Set(); + for (const match of indexHtml.matchAll(/]*\bsrc=["']([^"']+)["'][^>]*>/gi)) urls.add(match[1]); + for (const match of indexHtml.matchAll(/]*\bhref=["']([^"']+)["'][^>]*>/gi)) { + if (/rel=["']stylesheet["']/i.test(match[0])) urls.add(match[1]); + } + return [...urls]; +} + +function hashFile(filePath) { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +} + +function createCollector() { + const checks = []; + function push(status, id, message, details = {}) { + checks.push({ status, id, message, details }); + } + return { + checks, + pass: (id, message, details) => push('pass', id, message, details), + warn: (id, message, details) => push('warn', id, message, details), + fail: (id, message, details) => push('fail', id, message, details), + }; +} + +function summarize(checks, portalResults) { + const statusCounts = checks.reduce( + (summary, item) => { + summary[item.status] += 1; + return summary; + }, + { fail: 0, warn: 0, pass: 0 }, + ); + return { + ...statusCounts, + portals: portals.length, + distReady: portalResults.filter(item => item.dist?.exists && item.dist?.indexHtml).length, + runtimeConfigs: portalResults.filter(item => item.runtimeConfig?.exists).length, + }; +} + +function looksSecretish(value) { + const text = String(value || ''); + return forbiddenValuePatterns.some(pattern => pattern.test(text)); +} + +function publicRuntimeConfig(config) { + return { + portal: config.portal || config.TARO_APP_PORTAL || '', + apiBaseUrl: config.apiBaseUrl || config.TARO_APP_API_BASE_URL || '', + supabaseUrl: config.supabaseUrl || config.TARO_APP_SUPABASE_URL || '', + supabasePublishableKey: config.supabasePublishableKey || config.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '', + tenantCode: config.tenantCode || config.TARO_APP_TENANT_CODE || '', + }; +} + +function validateRuntimeConfig(filePath, expectedPortal, collector, options = {}) { + const required = Boolean(options.required); + const allowPlaceholderKey = Boolean(options.allowPlaceholderKey); + const result = { + exists: fs.existsSync(filePath), + file: relative(filePath), + portal: '', + apiBaseUrl: '', + supabaseUrl: '', + hasPublishableKey: false, + tenantCodeMode: '', + }; + + if (!result.exists) { + if (required) collector.fail(`runtime.${expectedPortal}.exists`, 'runtime-config.json is required for production release', { file: result.file }); + else collector.warn(`runtime.${expectedPortal}.exists`, 'runtime-config.json is not present; deploy must provide it beside index.html', { file: result.file }); + return result; + } + + let config; + try { + config = readJson(filePath); + } catch (error) { + collector.fail(`runtime.${expectedPortal}.json`, 'runtime config must be valid JSON', { file: result.file, error: error.message }); + return result; + } + + const keys = Object.keys(config); + const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key)); + const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.has(key)); + if (unknownKeys.length) collector.fail(`runtime.${expectedPortal}.keys`, 'runtime config has unknown keys', { file: result.file, unknownKeys }); + else collector.pass(`runtime.${expectedPortal}.keys`, 'runtime config uses only approved public keys', { file: result.file }); + if (forbiddenKeys.length) collector.fail(`runtime.${expectedPortal}.secrets`, 'runtime config includes forbidden server secret keys', { file: result.file, forbiddenKeys }); + else collector.pass(`runtime.${expectedPortal}.secrets`, 'runtime config contains no forbidden server secret keys', { file: result.file }); + + const publicConfig = publicRuntimeConfig(config); + result.portal = publicConfig.portal; + result.apiBaseUrl = publicConfig.apiBaseUrl; + result.supabaseUrl = publicConfig.supabaseUrl; + result.hasPublishableKey = Boolean(publicConfig.supabasePublishableKey); + result.tenantCodeMode = publicConfig.tenantCode ? 'fixed' : 'domain-resolved'; + + if (publicConfig.portal !== expectedPortal) collector.fail(`runtime.${expectedPortal}.portal`, 'runtime config portal does not match the H5 artifact', { file: result.file, expectedPortal, portal: publicConfig.portal }); + else collector.pass(`runtime.${expectedPortal}.portal`, 'runtime config portal matches the H5 artifact', { file: result.file }); + + if (!String(publicConfig.apiBaseUrl || '').startsWith('https://')) collector.fail(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl must be HTTPS for production H5 release', { file: result.file, apiBaseUrl: publicConfig.apiBaseUrl }); + else collector.pass(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl is HTTPS', { file: result.file }); + + if (!String(publicConfig.supabaseUrl || '').startsWith('https://')) collector.fail(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl must be HTTPS for production H5 release', { file: result.file, supabaseUrl: publicConfig.supabaseUrl }); + else collector.pass(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl is HTTPS', { file: result.file }); + + const publishableKey = String(publicConfig.supabasePublishableKey || ''); + if (!publishableKey) collector.fail(`runtime.${expectedPortal}.publishable_key`, 'supabasePublishableKey is required', { file: result.file }); + else if (publishableKey === 'replace-with-supabase-publishable-key' && allowPlaceholderKey) collector.pass(`runtime.${expectedPortal}.publishable_key`, 'runtime example uses explicit publishable-key placeholder', { file: result.file }); + else if (publishableKey === 'replace-with-supabase-publishable-key') collector.fail(`runtime.${expectedPortal}.publishable_key`, 'production runtime config still contains placeholder publishable key', { file: result.file }); + else collector.pass(`runtime.${expectedPortal}.publishable_key`, 'supabase publishable key is present', { file: result.file }); + + const secretValues = Object.entries(config).filter(([, value]) => looksSecretish(value)); + if (secretValues.length) { + collector.fail(`runtime.${expectedPortal}.secret_values`, 'runtime config contains secret-looking values', { + file: result.file, + keys: secretValues.map(([key]) => key), + }); + } else { + collector.pass(`runtime.${expectedPortal}.secret_values`, 'runtime config values look public-only', { file: result.file }); + } + + return result; +} + +function validatePackageScript(portal, collector) { + const rootPackageJson = readJson(path.join(repoRoot, 'package.json')); + const appPackageJson = readJson(path.join(taroRoot, 'package.json')); + const rootScript = rootPackageJson.scripts?.[portal.buildScript] || ''; + const appScript = appPackageJson.scripts?.[portal.appBuildScript] || ''; + if (!rootScript) { + collector.fail(`build.${portal.portal}.script`, 'Root H5 portal build script is missing', { script: portal.buildScript }); + return ''; + } + if (!rootScript.includes('@tiku-saas/taro') || !rootScript.includes(portal.appBuildScript)) { + collector.fail(`build.${portal.portal}.root_script`, 'Root H5 build script must delegate to the Taro workspace portal script', { + script: portal.buildScript, + command: rootScript, + appBuildScript: portal.appBuildScript, + }); + } else { + collector.pass(`build.${portal.portal}.root_script`, 'Root H5 build script delegates to the Taro workspace portal script', { + script: portal.buildScript, + command: rootScript, + appBuildScript: portal.appBuildScript, + }); + } + if (!appScript) { + collector.fail(`build.${portal.portal}.app_script`, 'Taro workspace H5 portal build script is missing', { script: portal.appBuildScript }); + } else if (!appScript.includes('TARO_APP_PORTAL') || !appScript.includes(portal.portal)) { + collector.fail(`build.${portal.portal}.portal_env`, 'Taro workspace H5 build script must pin TARO_APP_PORTAL', { script: portal.appBuildScript, command: appScript }); + } else { + collector.pass(`build.${portal.portal}.portal_env`, 'Taro workspace H5 build script pins TARO_APP_PORTAL', { script: portal.appBuildScript, command: appScript }); + } + return `npm run ${portal.buildScript}`; +} + +function inspectDist(portal, collector, options) { + const dir = path.join(distRoot, portal.dist); + const indexPath = path.join(dir, 'index.html'); + const result = { + exists: fs.existsSync(dir), + dir: relative(dir), + indexHtml: fs.existsSync(indexPath), + indexSha256: '', + files: 0, + totalBytes: 0, + assetReferences: 0, + }; + + if (!result.exists) { + if (options.requireDist) collector.fail(`dist.${portal.portal}.exists`, 'H5 dist directory is required for production release', { dir: result.dir }); + else collector.warn(`dist.${portal.portal}.exists`, 'H5 dist directory not found; run the portal build before deployment', { dir: result.dir }); + return result; + } + + collector.pass(`dist.${portal.portal}.exists`, 'H5 dist directory exists', { dir: result.dir }); + const files = walkFiles(dir); + result.files = files.length; + result.totalBytes = files.reduce((sum, filePath) => sum + fs.statSync(filePath).size, 0); + + if (!result.indexHtml) { + if (options.requireDist) collector.fail(`dist.${portal.portal}.index`, 'H5 index.html is missing', { file: relative(indexPath) }); + else collector.warn(`dist.${portal.portal}.index`, 'H5 index.html is missing; run the portal build before deployment', { file: relative(indexPath) }); + return result; + } + + const indexHtml = readText(indexPath); + result.indexSha256 = hashFile(indexPath); + result.assetReferences = parseAssetUrls(indexHtml).length; + if (!/
{ + const buildCommand = validatePackageScript(portal, collector); + const runtimeExample = validateRuntimeConfig(path.join(deployRoot, portal.runtimeExample), portal.portal, collector, { + required: true, + allowPlaceholderKey: true, + }); + const dist = inspectDist(portal, collector, options); + const runtimeConfig = validateRuntimeConfig(path.join(distRoot, portal.dist, 'runtime-config.json'), portal.portal, collector, { + required: options.requireRuntimeConfig, + allowPlaceholderKey: false, + }); + + return { + portal: portal.portal, + buildCommand, + landingPath: portal.landingPath, + deployHint: portal.deployHint, + dist, + runtimeExample, + runtimeConfig, + }; + }); + + const summary = summarize(collector.checks, portalResults); + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + summary, + portals: portalResults, + checks: collector.checks, + }; +} + +function printHuman(manifest) { + const { summary } = manifest; + console.log(`Taro H5 release manifest: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`); + for (const portal of manifest.portals) { + console.log(`[${portal.portal}] ${portal.buildCommand}`); + console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`); + console.log(` landing=${portal.landingPath}`); + } + for (const item of manifest.checks.filter(check => check.status !== 'pass')) { + const marker = item.status === 'fail' ? 'FAIL' : 'WARN'; + console.log(`[${marker}] ${item.id}: ${item.message}`); + } +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const manifest = buildManifest(options); + + if (options.writePath) { + fs.mkdirSync(path.dirname(options.writePath), { recursive: true }); + fs.writeFileSync(options.writePath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + } + + if (options.json) console.log(JSON.stringify(manifest, null, 2)); + else printHuman(manifest); + + if (manifest.summary.fail > 0) process.exitCode = 1; +} + +main();