forked from wangziqi/gongxue-base
test: add taro h5 release manifest gate
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
108
scripts/taro-h5-release-manifest-test.js
Normal file
108
scripts/taro-h5-release-manifest-test.js
Normal file
@@ -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');
|
||||
402
scripts/taro-h5-release-manifest.js
Normal file
402
scripts/taro-h5-release-manifest.js
Normal file
@@ -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(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) urls.add(match[1]);
|
||||
for (const match of indexHtml.matchAll(/<link\b[^>]*\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 (!/<div id=["']app["']/.test(indexHtml) || !/<script\b/i.test(indexHtml)) {
|
||||
collector.fail(`dist.${portal.portal}.index`, 'H5 index.html must contain the app root and script tags', { file: relative(indexPath) });
|
||||
} else {
|
||||
collector.pass(`dist.${portal.portal}.index`, 'H5 index.html is deployable', {
|
||||
file: relative(indexPath),
|
||||
sha256: result.indexSha256,
|
||||
assetReferences: result.assetReferences,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.assetReferences < 2) {
|
||||
collector.warn(`dist.${portal.portal}.assets`, 'H5 index.html has few asset references; verify the build output manually', { assetReferences: result.assetReferences });
|
||||
} else {
|
||||
collector.pass(`dist.${portal.portal}.assets`, 'H5 index.html references JS/CSS assets', { assetReferences: result.assetReferences });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildManifest(options) {
|
||||
const collector = createCollector();
|
||||
const portalResults = portals.map(portal => {
|
||||
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();
|
||||
Reference in New Issue
Block a user