Files
gongxue-base/scripts/production-launch-gate.js
2026-07-12 19:26:57 +08:00

1633 lines
62 KiB
JavaScript

import fs from 'node:fs';
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { hashArtifactDirectory } from './release-artifact-hash.js';
const defaultEvidencePath = path.resolve(process.cwd(), 'docs/refactor/production-launch-evidence.json');
const defaultMaxAgeDays = 14;
const defaultLiveTimeoutMs = 10_000;
const h5PortalTargets = [
{ portal: 'student', targetKey: 'studentH5Url' },
{ portal: 'tenant-admin', targetKey: 'tenantAdminH5Url' },
{ portal: 'platform-admin', targetKey: 'platformAdminH5Url' },
];
const gateChecks = [
{
id: 'readiness.production.env',
label: 'Production environment readiness',
commandIncludes: 'readiness:production',
artifactSummary: nestedJsonSummaryArtifact,
summary: { blocker: 0 },
},
{
id: 'readiness.production.db',
label: 'Production database readiness',
commandIncludes: 'readiness:production:db',
artifactSummary: nestedJsonSummaryArtifact,
summary: { blocker: 0 },
},
{
id: 'db.migration-history',
label: 'Production database migration history',
commandIncludes: 'db.migrations.current',
artifactSummary: migrationHistoryArtifactSummary,
summary: {
status: 'pass',
failed: 0,
schemaVersion: 1,
latestRepositoryMigration: { truthy: true },
latestAppliedMigration: { truthy: true },
missingMigrations: [],
readinessArtifactSha256: { truthy: true },
},
},
{
id: 'postgres.tuning-evidence',
label: 'PostgreSQL 4c16g tuning evidence',
commandIncludes: 'perf:postgres:evidence',
artifactSummary: directJsonArtifactSummary,
summary: {
status: 'pass',
profile: { oneOf: ['shared-host', 'dedicated-db'] },
failures: 0,
pgStatStatementsAvailable: true,
pendingRestart: 0,
},
},
{
id: 'auth.remote-smoke',
label: 'Remote Supabase Auth/JWKS smoke',
commandIncludes: 'smoke:auth:remote',
summary: { failed: 0, requireAdminTokens: true },
},
{
id: 'auth.platform-admin-bootstrap',
label: 'First platform administrator bootstrap and Auth identity binding',
commandIncludes: 'bootstrap:platform-admin',
artifactSummary: platformAdminBootstrapArtifactSummary,
summary: {
status: 'pass',
failed: 0,
schemaVersion: 1,
dryRunVerified: true,
applied: true,
adminUserIdSha256: { truthy: true },
authSmokeExpectedUserIdSha256: { truthy: true },
identityMatches: true,
auditEvent: 'platform.admin.bootstrapped',
auditVerified: true,
dryRunArtifactSha256: { truthy: true },
applyArtifactSha256: { truthy: true },
authSmokeArtifactSha256: { truthy: true },
auditArtifactSha256: { truthy: true },
},
},
{
id: 'auth.sms-pnvs-diagnostics',
label: 'Aliyun PNVS SMS provider diagnostics',
commandIncludes: 'diagnose:aliyun-pnvs',
artifactSummary: directJsonArtifactSummary,
summary: {
ok: true,
'env.providerMatchesPnvs': true,
'provider.found': true,
'provider.provider': 'aliyun-pnvs',
'provider.status': { oneOf: ['active', 'testing'] },
'provider.publicConfig.templateParamHasCodePlaceholder': true,
'secret.found': true,
'secret.accessKeyIdLength': { gt: 0 },
'secret.accessKeySecretLength': { gt: 0 },
},
},
{
id: 'auth.sms-pnvs-remote-smoke',
label: 'Remote Aliyun PNVS SMS login smoke',
commandIncludes: 'smoke:sms-login:remote',
artifactSummary: directJsonArtifactSummary,
summary: {
failed: 0,
provider: 'aliyun-pnvs',
loginVerified: true,
authMe: true,
},
},
{
id: 'rls.tenant-isolation',
label: 'Runtime tenant RLS isolation',
commandIncludes: 'test:rls',
summary: { failed: 0 },
},
{
id: 'db.tenant-foreign-key-audit',
label: 'Tenant foreign key full-data integrity audit',
commandIncludes: 'audit:tenant-foreign-keys',
artifactSummary: directJsonArtifactSummary,
summary: {
status: 'pass',
kind: 'tenant-foreign-key-audit',
'safety.databaseEnvironment': { oneOf: ['local', 'test', 'ci'] },
'schema.schemaMatches': true,
'schema.relationCount': { gte: 189 },
'schema.exceptionCount': 3,
'data.auditedRelations': { gte: 189 },
'data.invalidRelations': 0,
},
},
{
id: 'migration.pb-production-dry-run',
label: 'PocketBase production dry-run',
commandIncludes: 'pb:import:dry-run',
artifactSummary: pocketBaseDryRunArtifactSummary,
summary: {
blocker: 0,
warning: 0,
migrationProfile: 'production',
requiredCollectionsMissing: 0,
criticalFieldCoverageWarnings: 0,
},
},
{
id: 'migration.pb-import-validate',
label: 'PocketBase import validation',
commandIncludes: 'pb:import:validate',
summary: { fail: 0 },
},
{
id: 'migration.pb-import-sample',
label: 'PocketBase imported business sampling',
commandIncludes: 'pb:import:sample',
summary: { fail: 0 },
},
{
id: 'performance.api-real-data-read',
label: 'Real-data API read benchmark',
commandIncludes: 'perf:api:local',
summary: {
errors: 0,
errorRate: { lte: 0.001 },
p95Ms: { lte: 300 },
p99Ms: { lte: 800 },
concurrency: { gte: 30 },
durationSeconds: { gte: 120 },
includeWrites: false,
},
},
{
id: 'performance.api-real-data-mixed',
label: 'Real-data API mixed read/write benchmark',
commandIncludes: 'perf:api:local',
summary: {
errors: 0,
errorRate: { lte: 0.001 },
p95Ms: { lte: 500 },
p99Ms: { lte: 1200 },
concurrency: { gte: 50 },
durationSeconds: { gte: 60 },
includeWrites: true,
},
},
{
id: 'performance.tenant-students-100k',
label: 'Single-tenant 100k student list capacity evidence',
commandIncludes: 'perf:tenant-students:evidence',
artifactSummary: tenantStudentCapacityArtifactSummary,
summary: {
databaseEnvironment: { oneOf: ['test', 'ci'] },
'fixture.platformUsers': { gte: 100000 },
'fixture.tenantMemberships': { gte: 100000 },
'fixture.studentProfiles': { gte: 100000 },
caseCount: { gte: 5 },
deepCursorApproximateOffset: { gte: 90000 },
firstPageP95Ms: { lte: 100 },
deepCursorP95Ms: { lte: 100 },
searchP95MaxMs: { lte: 250 },
keysetIndexUsed: true,
trigramIndexUsed: true,
cleanupVerified: true,
remainingManagedUsers: 0,
},
},
{
id: 'api.dynamic-tenant-cors-smoke',
label: 'Dynamic active-tenant CORS production smoke',
commandIncludes: 'smoke:tenant-cors:remote',
artifactSummary: directJsonArtifactSummary,
summary: {
failed: 0,
activeTenantOriginAllowed: true,
unknownOriginDenied: true,
disabledOriginDenied: true,
noOriginHealthAllowed: true,
},
},
{
id: 'deploy.linux-systemd-verify',
label: 'Linux systemd API, worker and timer installation verification',
commandIncludes: 'systemd-analyze verify',
summary: {
failed: 0,
apiServiceActive: true,
workerTargetActive: true,
workerJobServices: { gte: 9 },
enabledTimers: { gte: 6 },
},
},
{
id: 'backup.restore-drill',
label: 'Isolated production backup restore drill',
commandIncludes: 'RESTORE_DRILL_VERIFY_COMMAND',
artifactSummary: backupRestoreDrillArtifactSummary,
summary: {
status: 'pass',
failed: 0,
schemaVersion: 1,
snapshotId: { truthy: true },
restoreTarget: { truthy: true },
isolated: true,
integrityVerified: true,
rtoMinutes: { gte: 0 },
rpoMinutes: { gte: 0 },
verificationArtifactSha256: { truthy: true },
},
},
{
id: 'api.launch-persona-smoke',
label: 'Real API launch persona journey smoke',
commandIncludes: 'smoke:launch-persona',
artifactSummary: directJsonArtifactSummary,
summary: {
status: 'pass',
authMode: 'app_session',
'student.status': 'pass',
'student.result.entitlement.isSvip': true,
'student.result.practice.questionCount': { gte: 1 },
'student.result.favorite.favoriteCount': { gte: 1 },
'student.result.favorite.reviewSessionId': { truthy: true },
'student.result.wrongReview.reviewPlanCount': { gte: 0 },
'tenantAdmin.status': 'pass',
'tenantAdmin.result.guards.studentDashboardDenied': { oneOf: ['TENANT_ADMIN_REQUIRED', 'TENANT_MEMBER_REQUIRED', 'TENANT_PERMISSION_DENIED', 'FORBIDDEN'] },
'tenantAdmin.result.guards.crossTenantDenied': { oneOf: ['AUTH_TENANT_MISMATCH', 'TENANT_MEMBER_REQUIRED', 'TENANT_PERMISSION_DENIED', 'FORBIDDEN'] },
'platformAdmin.status': 'pass',
'platformAdmin.result.guards.studentPlatformDenied': { oneOf: ['PLATFORM_ADMIN_REQUIRED', 'FORBIDDEN'] },
},
},
{
id: 'api.integration',
label: 'API integration regression',
commandIncludes: 'test:api',
summary: { failed: 0 },
},
{
id: 'worker.assets',
label: 'Asset worker security regression',
commandIncludes: 'test:worker:assets',
summary: { failed: 0 },
},
{
id: 'worker.commerce',
label: 'Commerce/payment worker regression',
commandIncludes: 'test:worker:commerce',
summary: { failed: 0 },
},
{
id: 'worker.platform-billing',
label: 'Platform subscription billing worker regression',
commandIncludes: 'test:worker:platform-billing',
summary: { failed: 0 },
},
{
id: 'worker.platform-dunning',
label: 'Platform invoice dunning worker regression',
commandIncludes: 'test:worker:platform-dunning',
summary: { failed: 0 },
},
{
id: 'worker.imports',
label: 'Async import worker regression',
commandIncludes: 'test:worker:imports',
summary: { failed: 0 },
},
{
id: 'worker.public-banks',
label: 'Public question bank worker regression',
commandIncludes: 'test:worker:public-banks',
summary: { failed: 0 },
},
{
id: 'taro.check',
label: 'Taro type/security guardrails',
commandIncludes: 'check:taro',
summary: { failed: 0 },
},
{
id: 'taro.supply-chain',
label: 'Taro secured bundle dependencies and reviewed toolchain risk',
commandIncludes: 'audit:taro:supply-chain',
artifactSummary: taroSupplyChainArtifactSummary,
summary: {
schemaVersion: 1,
status: 'pass-with-reviewed-toolchain-risk',
'securedBundleDependencies.swiper': '12.1.2',
'securedBundleDependencies.lodash-es': '4.18.1',
'audit.counts.critical': { lte: 3 },
'audit.counts.high': { lte: 10 },
reviewedInvalidEdgeCount: 4,
riskBoundaryPresent: true,
riskControlCount: { gte: 1 },
},
},
{
id: 'taro.build.student',
label: 'Student H5 build',
commandIncludes: 'build:taro:h5:student',
summary: { failed: 0 },
},
{
id: 'taro.build.tenant',
label: 'Tenant admin H5 build',
commandIncludes: 'build:taro:h5:tenant',
summary: { failed: 0 },
},
{
id: 'taro.build.platform',
label: 'Platform admin H5 build',
commandIncludes: 'build:taro:h5:platform',
summary: { failed: 0 },
},
{
id: 'taro.h5-static-smoke',
label: 'Taro H5 static startup smoke',
commandIncludes: 'smoke:taro:h5',
artifactSummary: nestedJsonSummaryArtifact,
summary: {
fail: 0,
portals: 3,
tenantResolveRequests: 3,
},
},
{
id: 'taro.h5-interaction-smoke',
label: 'Taro H5 real browser interaction smoke',
commandIncludes: 'smoke:taro:h5:interaction',
artifactSummary: h5InteractionArtifactSummary,
summary: {
fail: 0,
pass: { gte: 32 },
'mockApi.keyRequests.answers': { gte: 1 },
'mockApi.keyRequests.favorites': { gte: 1 },
'mockApi.keyRequests.ordersCreated': { gte: 1 },
'mockApi.keyRequests.paymentsCreated': { gte: 1 },
'mockApi.keyRequests.orderStatus': { gte: 1 },
'mockApi.keyRequests.tenantResolve': { gte: 3 },
'mockApi.keyRequests.tenantContentWrites': { gte: 1 },
'mockApi.keyRequests.tenantAdminWrites': { gte: 1 },
'mockApi.keyRequests.crmWrites': { gte: 1 },
'mockApi.keyRequests.commissionWrites': { gte: 1 },
'mockApi.keyRequests.platformAdminWrites': { gte: 1 },
},
},
{
id: 'taro.h5-release-guardrails',
label: 'Taro H5 release artifact guardrails',
commandIncludes: 'taro-h5-release-guardrails-test.js',
artifactSummary: nestedJsonSummaryArtifact,
summary: {
fail: 0,
warn: 0,
},
},
{
id: 'taro.h5-release-manifest',
label: 'Taro H5 release deployment manifest',
commandIncludes: 'manifest:taro:h5',
artifactSummary: nestedJsonSummaryArtifact,
summary: {
fail: 0,
warn: 0,
portals: 3,
distReady: 3,
runtimeConfigs: 3,
treeHashes: 3,
},
},
{
id: 'audit.runtime',
label: 'Runtime dependency audit',
commandIncludes: 'audit:runtime',
summary: { critical: 0, high: 0 },
},
{
id: 'security.repo-scan',
label: 'Repository static security scan',
commandIncludes: 'security:repo',
artifactSummary: nestedJsonSummaryArtifact,
summary: { critical: 0, high: 0 },
},
{
id: 'security.codex-scan',
label: 'Codex Security scan',
commandIncludes: 'codex-security',
summary: { critical: 0, high: 0 },
},
];
const requiredAttestations = [
{
id: 'backup.snapshot',
label: 'Production database backup/snapshot is prepared',
},
{
id: 'rollback.plan',
label: 'Rollback plan and old PocketBase read-only snapshot are prepared',
},
{
id: 'migration.sampling',
label: 'Real migrated users/questions/orders/assets were sampled',
},
{
id: 'provider.production-accounts',
label: 'SMS/OAuth/payment production accounts and callback domains were verified',
},
{
id: 'object-storage.production-controls',
label: 'Object storage AV/content scan, CDN boundary, watermark and lifecycle controls were verified',
},
{
id: 'payment.reconciliation-sampling',
label: 'Real payment/refund bill samples were reconciled',
},
{
id: 'frontend.runtime-config-review',
label: 'Three H5 runtime-config.json files were reviewed for public-only values',
},
];
const weappGateChecks = [
{
id: 'taro.build.weapp-student',
label: 'Student production WeApp build',
commandIncludes: 'build:taro:weapp:student:production',
summary: { failed: 0 },
},
{
id: 'taro.weapp-release-guardrails',
label: 'Student WeApp release artifact guardrails',
commandIncludes: 'taro-weapp-release-guardrails.js --production',
artifactSummary: nestedJsonSummaryArtifact,
summary: { fail: 0, warn: 0 },
},
];
const weappAttestations = [
{
id: 'frontend.weapp-devtools-device-review',
label: 'Student WeApp was verified in WeChat DevTools and on a real device',
},
];
const h5ReleaseDirectories = new Map([
['student', 'apps/taro/dist/h5-student'],
['tenant-admin', 'apps/taro/dist/h5-tenant-admin'],
['platform-admin', 'apps/taro/dist/h5-platform-admin'],
]);
function parseArgs(argv) {
const options = {
evidencePath: defaultEvidencePath,
json: false,
maxAgeDays: defaultMaxAgeDays,
allowStale: false,
verifyLiveH5: ['1', 'true', 'yes', 'on'].includes(String(process.env.LAUNCH_GATE_VERIFY_LIVE_H5 || '').trim().toLowerCase()),
liveTimeoutMs: Number(process.env.LAUNCH_GATE_LIVE_TIMEOUT_MS || defaultLiveTimeoutMs),
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--json') options.json = true;
else if (arg === '--allow-stale') options.allowStale = true;
else if (arg === '--verify-live-h5') options.verifyLiveH5 = true;
else if (arg === '--no-verify-live-h5') options.verifyLiveH5 = false;
else if (arg === '--evidence') {
options.evidencePath = path.resolve(process.cwd(), argv[index + 1] || '');
index += 1;
} else if (arg.startsWith('--evidence=')) {
options.evidencePath = path.resolve(process.cwd(), arg.slice('--evidence='.length));
} else if (arg === '--max-age-days') {
options.maxAgeDays = Number(argv[index + 1]);
index += 1;
} else if (arg.startsWith('--max-age-days=')) {
options.maxAgeDays = Number(arg.slice('--max-age-days='.length));
} else if (arg === '--live-timeout-ms') {
options.liveTimeoutMs = Number(argv[index + 1]);
index += 1;
} else if (arg.startsWith('--live-timeout-ms=')) {
options.liveTimeoutMs = Number(arg.slice('--live-timeout-ms='.length));
}
}
if (!Number.isFinite(options.maxAgeDays) || options.maxAgeDays <= 0) options.maxAgeDays = defaultMaxAgeDays;
if (!Number.isFinite(options.liveTimeoutMs) || options.liveTimeoutMs < 1_000 || options.liveTimeoutMs > 60_000) {
options.liveTimeoutMs = defaultLiveTimeoutMs;
}
return options;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function sha256Buffer(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function productionUrlFailure(value) {
const text = String(value || '').trim();
if (!text) return 'URL is missing';
let parsed;
try {
parsed = new URL(text);
} catch {
return 'URL is invalid';
}
if (parsed.protocol !== 'https:') return 'URL must use HTTPS';
if (parsed.username || parsed.password) return 'URL must not include credentials';
if (parsed.hash) return 'URL must not include a fragment';
if (/[<>]|replace(?:-with)?|placeholder|your[-_. ]?(?:domain|host|url)/i.test(text)) {
return 'URL contains a placeholder value';
}
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
const placeholderHost =
hostname === 'example.com'
|| hostname.endsWith('.example.com')
|| hostname === 'example'
|| hostname.endsWith('.example')
|| hostname.endsWith('.test')
|| hostname.endsWith('.invalid')
|| /(^|\.)(?:replace(?:-with)?|placeholder|your-domain|your-host)(?:\.|$)/i.test(hostname);
if (placeholderHost) return 'URL uses a placeholder or reserved hostname';
const localHost =
hostname === 'localhost'
|| hostname.endsWith('.localhost')
|| hostname === '::1'
|| hostname === '0.0.0.0'
|| hostname.startsWith('127.');
if (localHost) return 'URL must not resolve to a local development hostname';
return '';
}
function normalizedUrl(value) {
const parsed = new URL(String(value || '').trim());
parsed.hash = '';
parsed.search = '';
return parsed.href.replace(/\/+$/, '');
}
function portalEvidence(items, portal) {
if (Array.isArray(items)) return items.find(item => item?.portal === portal) || null;
if (items && typeof items === 'object') return items[portal] || null;
return null;
}
function appAssetPathFromIndex(indexHtml) {
const sources = [...String(indexHtml || '').matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)].map(match => match[1]);
return sources.find(source => /(?:^|\/)app(?:\.[^/]+)?\.js(?:\?|$)/i.test(source)) || sources.at(-1) || '';
}
function normalizeAssetPath(value, baseUrl) {
const resolved = new URL(String(value || ''), baseUrl);
return `${resolved.pathname}${resolved.search}`;
}
function validateHash(value) {
return /^[0-9a-f]{64}$/i.test(String(value || ''));
}
function normalizeStatus(value) {
return String(value || '').trim().toLowerCase();
}
function isApproved(value) {
return ['approved', 'pass', 'passed', 'ok', 'complete', 'completed'].includes(normalizeStatus(value));
}
function findById(items, id) {
return (Array.isArray(items) ? items : []).find(item => item && item.id === id);
}
function resolveArtifact(evidencePath, artifact) {
if (!artifact) return '';
if (path.isAbsolute(artifact)) return artifact;
return path.resolve(path.dirname(evidencePath), artifact);
}
function daysSince(value, now = Date.now()) {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return Number.POSITIVE_INFINITY;
return (now - timestamp) / (24 * 60 * 60 * 1000);
}
function hasPath(object, keyPath) {
const parts = keyPath.split('.');
let cursor = object;
for (const part of parts) {
if (!cursor || typeof cursor !== 'object' || !(part in cursor)) return false;
cursor = cursor[part];
}
return true;
}
function valueAt(object, keyPath) {
const parts = keyPath.split('.');
let cursor = object;
for (const part of parts) cursor = cursor?.[part];
return cursor;
}
function compareSummary(actualSummary, expectedSummary) {
const failures = [];
for (const [key, expectedValue] of Object.entries(expectedSummary || {})) {
if (!hasPath(actualSummary || {}, key)) {
failures.push(`${key} is missing`);
continue;
}
const actualValue = valueAt(actualSummary, key);
const comparisonFailure = compareExpectedValue(actualValue, expectedValue);
if (comparisonFailure) {
failures.push(`${key} ${comparisonFailure}`);
}
}
return failures;
}
function directJsonArtifactSummary(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('artifact must contain a JSON object');
}
return payload;
}
function nestedJsonSummaryArtifact(payload) {
const artifact = directJsonArtifactSummary(payload);
return directJsonArtifactSummary(artifact.summary);
}
function pocketBaseDryRunArtifactSummary(payload) {
const artifact = directJsonArtifactSummary(payload);
const summary = directJsonArtifactSummary(artifact.summary);
const requiredCollections = artifact.migrationReadiness?.requiredCollections;
const criticalFieldCoverage = artifact.migrationReadiness?.criticalFieldCoverage;
if (!Array.isArray(requiredCollections) || !Array.isArray(criticalFieldCoverage)) {
throw new Error('artifact must contain migrationReadiness requiredCollections and criticalFieldCoverage arrays');
}
return {
blocker: Number(summary.blockers),
warning: Number(summary.warnings),
migrationProfile: artifact.migrationProfile,
requiredCollectionsMissing: requiredCollections.filter(item => item?.present !== true).length,
criticalFieldCoverageWarnings: criticalFieldCoverage.filter(item => item?.present !== true).length,
};
}
function h5InteractionArtifactSummary(payload) {
const artifact = directJsonArtifactSummary(payload);
const summary = directJsonArtifactSummary(artifact.summary);
return {
...summary,
mockApi: artifact.mockApi,
};
}
function latestRepositoryMigrationVersion() {
const migrationsDir = path.resolve(process.cwd(), 'supabase', 'migrations');
if (!fs.existsSync(migrationsDir)) throw new Error(`repository migration directory is missing: ${migrationsDir}`);
const versions = fs.readdirSync(migrationsDir)
.filter(file => file.endsWith('.sql'))
.map(file => /^(\d+)_.*\.sql$/.exec(file)?.[1] || '')
.filter(Boolean)
.sort((left, right) => left.length - right.length || left.localeCompare(right));
if (versions.length === 0) throw new Error('repository contains no numeric Supabase migrations');
return versions.at(-1);
}
function migrationHistoryArtifactSummary(payload, context = {}) {
const artifact = directJsonArtifactSummary(payload);
const latestRepositoryMigration = String(artifact.latestRepositoryMigration || '').trim();
const latestAppliedMigration = String(artifact.latestAppliedMigration || '').trim();
const repositoryLatest = latestRepositoryMigrationVersion();
if (latestRepositoryMigration !== repositoryLatest) {
throw new Error(`artifact repository migration ${latestRepositoryMigration || '(missing)'} does not match ${repositoryLatest}`);
}
if (!/^\d+$/.test(latestAppliedMigration) || compareMigrationVersion(latestAppliedMigration, repositoryLatest) < 0) {
throw new Error(`artifact applied migration ${latestAppliedMigration || '(missing)'} is older than ${repositoryLatest}`);
}
if (!Array.isArray(artifact.missingMigrations)) throw new Error('artifact missingMigrations must be an array');
const readinessArtifactSha256 = requireSha256(artifact.readinessArtifactSha256, 'readinessArtifactSha256');
const readinessEvidence = findById(context.evidence?.checks, 'readiness.production.db');
if (!readinessEvidence?.artifact) throw new Error('readiness.production.db artifact reference is missing');
const readinessArtifactPath = resolveArtifact(context.options?.evidencePath || '', readinessEvidence.artifact);
if (!fs.existsSync(readinessArtifactPath) || !fs.statSync(readinessArtifactPath).isFile()) {
throw new Error('readiness.production.db artifact file is missing');
}
const actualReadinessSha256 = sha256File(readinessArtifactPath);
if (readinessArtifactSha256 !== actualReadinessSha256) {
throw new Error('readinessArtifactSha256 does not match the readiness.production.db artifact');
}
return {
status: artifact.status,
failed: Number(artifact.failed),
schemaVersion: Number(artifact.schemaVersion),
latestRepositoryMigration,
latestAppliedMigration,
missingMigrations: artifact.missingMigrations,
readinessArtifactSha256,
};
}
function compareMigrationVersion(left, right) {
const normalizedLeft = String(left || '').replace(/^0+(?=\d)/, '');
const normalizedRight = String(right || '').replace(/^0+(?=\d)/, '');
if (normalizedLeft.length !== normalizedRight.length) return normalizedLeft.length > normalizedRight.length ? 1 : -1;
return normalizedLeft === normalizedRight ? 0 : normalizedLeft > normalizedRight ? 1 : -1;
}
function requireSha256(value, field) {
const normalized = String(value || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error(`${field} must be a SHA-256 value`);
return normalized;
}
function platformAdminBootstrapArtifactSummary(payload) {
const artifact = directJsonArtifactSummary(payload);
const adminUserIdSha256 = requireSha256(artifact.adminUserIdSha256, 'adminUserIdSha256');
const authSmokeExpectedUserIdSha256 = requireSha256(
artifact.authSmokeExpectedUserIdSha256,
'authSmokeExpectedUserIdSha256',
);
return {
status: artifact.status,
failed: Number(artifact.failed),
schemaVersion: Number(artifact.schemaVersion),
dryRunVerified: artifact.dryRunVerified === true,
applied: artifact.applied === true,
adminUserIdSha256,
authSmokeExpectedUserIdSha256,
identityMatches: adminUserIdSha256 === authSmokeExpectedUserIdSha256,
auditEvent: artifact.auditEvent,
auditVerified: artifact.auditVerified === true,
dryRunArtifactSha256: requireSha256(artifact.dryRunArtifactSha256, 'dryRunArtifactSha256'),
applyArtifactSha256: requireSha256(artifact.applyArtifactSha256, 'applyArtifactSha256'),
authSmokeArtifactSha256: requireSha256(artifact.authSmokeArtifactSha256, 'authSmokeArtifactSha256'),
auditArtifactSha256: requireSha256(artifact.auditArtifactSha256, 'auditArtifactSha256'),
};
}
function backupRestoreDrillArtifactSummary(payload) {
const artifact = directJsonArtifactSummary(payload);
const snapshotId = String(artifact.snapshotId || '').trim();
const restoreTarget = String(artifact.restoreTarget || '').trim();
if (!snapshotId || !restoreTarget) throw new Error('artifact must identify the snapshot and isolated restore target');
const rtoMinutes = Number(artifact.rtoMinutes);
const rpoMinutes = Number(artifact.rpoMinutes);
if (!Number.isFinite(rtoMinutes) || !Number.isFinite(rpoMinutes)) {
throw new Error('artifact rtoMinutes and rpoMinutes must be numeric');
}
return {
status: artifact.status,
failed: Number(artifact.failed),
schemaVersion: Number(artifact.schemaVersion),
snapshotId,
restoreTarget,
isolated: artifact.isolated === true,
integrityVerified: artifact.integrityVerified === true,
rtoMinutes,
rpoMinutes,
verificationArtifactSha256: requireSha256(
artifact.verificationArtifactSha256,
'verificationArtifactSha256',
),
};
}
function taroSupplyChainArtifactSummary(payload) {
const artifact = directJsonArtifactSummary(payload);
const securedBundleDependencies = directJsonArtifactSummary(artifact.securedBundleDependencies);
const audit = directJsonArtifactSummary(artifact.audit);
const counts = directJsonArtifactSummary(audit.counts);
const riskBoundary = directJsonArtifactSummary(artifact.riskBoundary);
if (!Array.isArray(artifact.npmLs?.edges)) throw new Error('artifact npmLs.edges must be an array');
const expectedEdges = new Set([
'@tarojs/components>swiper@11.1.15',
'@tarojs/components-react>swiper@11.1.15',
'@tarojs/plugin-platform-h5>lodash-es@4.17.21',
'@tarojs/taro-h5>lodash-es@4.17.21',
]);
const actualEdges = new Set(artifact.npmLs.edges.map(edge => `${edge?.parent}>${edge?.dependency}@${edge?.declared}`));
if (actualEdges.size !== expectedEdges.size || [...expectedEdges].some(edge => !actualEdges.has(edge))) {
throw new Error('artifact npmLs.edges does not match the four reviewed Taro exact-dependency edges');
}
if (!String(riskBoundary.appliesTo || '').trim()) throw new Error('artifact riskBoundary.appliesTo is required');
if (!Array.isArray(riskBoundary.controls)) throw new Error('artifact riskBoundary.controls must be an array');
return {
schemaVersion: Number(artifact.schemaVersion),
status: artifact.status,
securedBundleDependencies,
audit: { counts },
reviewedInvalidEdgeCount: actualEdges.size,
riskBoundaryPresent: true,
riskControlCount: riskBoundary.controls.filter(item => String(item || '').trim()).length,
};
}
function tenantStudentCapacityArtifactSummary(payload) {
if (payload?.schemaVersion !== 1 || payload?.kind !== 'tenant-student-capacity') {
throw new Error('artifact is not tenant-student-capacity schemaVersion=1');
}
const cases = Array.isArray(payload.cases) ? payload.cases : [];
const byId = new Map(cases.map(item => [item?.id, item]));
const firstPage = byId.get('first-page');
const deepCursor = byId.get('deep-cursor');
const searchCases = ['name-substring', 'phone-substring', 'email-substring'].map(id => byId.get(id));
if (!firstPage || !deepCursor || searchCases.some(item => !item)) {
throw new Error('artifact must contain first-page, deep-cursor and three substring search cases');
}
const caseIndexes = item => Array.isArray(item?.explain?.summary?.indexes) ? item.explain.summary.indexes : [];
const searchP95Values = searchCases.map(item => Number(item?.latencyMs?.p95));
if (searchP95Values.some(value => !Number.isFinite(value))) {
throw new Error('artifact search cases must contain numeric latencyMs.p95 values');
}
const remaining = payload.cleanup?.remaining || {};
const remainingManagedUsers = [remaining.tenants, remaining.platformUsers, remaining.memberships, remaining.profiles]
.reduce((sum, value) => sum + Number(value || 0), 0);
return {
databaseEnvironment: payload.safety?.databaseEnvironment || '',
fixture: payload.fixture || {},
caseCount: cases.length,
deepCursorApproximateOffset: Number(payload.config?.deepCursorApproximateOffset || 0),
firstPageP95Ms: Number(firstPage?.latencyMs?.p95),
deepCursorP95Ms: Number(deepCursor?.latencyMs?.p95),
searchP95MaxMs: Math.max(...searchP95Values),
keysetIndexUsed: [firstPage, deepCursor].every(item => caseIndexes(item).includes('idx_memberships_student_keyset_page')),
trigramIndexUsed: searchCases.every(item => caseIndexes(item).includes('idx_platform_users_identity_search_trgm')),
cleanupVerified: payload.cleanup?.cleanupVerified === true,
remainingManagedUsers,
};
}
function validateStructuredArtifact(spec, item, artifactPath, evidence, options, collector) {
if (typeof spec.artifactSummary !== 'function') return;
let payload;
try {
payload = readJson(artifactPath);
} catch (error) {
collector.block(`check.${spec.id}.artifact_json`, `${spec.label} artifact must be valid JSON`, {
artifact: item.artifact,
error: error instanceof Error ? error.message : String(error),
});
return;
}
let derived;
try {
derived = spec.artifactSummary(payload, { evidence, options, item, artifactPath });
} catch (error) {
collector.block(`check.${spec.id}.artifact_summary`, `${spec.label} artifact structure is invalid`, {
artifact: item.artifact,
error: error instanceof Error ? error.message : String(error),
});
return;
}
const gateFailures = compareSummary(derived, spec.summary);
const evidenceFailures = [];
for (const key of Object.keys(spec.summary || {})) {
if (!hasPath(item.summary || {}, key)) {
evidenceFailures.push(`${key} is missing from evidence summary`);
continue;
}
if (!hasPath(derived, key) || JSON.stringify(valueAt(item.summary, key)) !== JSON.stringify(valueAt(derived, key))) {
evidenceFailures.push(`${key} does not match the artifact`);
}
}
const comparablePaths = new Set(Object.keys(spec.summary || {}));
for (const key of leafPaths(item.summary || {})) {
if ([...comparablePaths].some(pathKey => key === pathKey || key.startsWith(`${pathKey}.`))) continue;
evidenceFailures.push(`${key} is not derived from an artifact-backed gate field`);
}
if (gateFailures.length || evidenceFailures.length) {
collector.block(`check.${spec.id}.artifact_summary`, `${spec.label} artifact does not satisfy or match evidence`, {
gateFailures,
evidenceFailures,
derived,
});
} else {
collector.pass(`check.${spec.id}.artifact_summary`, `${spec.label} artifact directly satisfies and matches evidence`);
}
}
function leafPaths(object, prefix = '') {
if (!object || typeof object !== 'object' || Array.isArray(object)) return prefix ? [prefix] : [];
const paths = [];
for (const [key, value] of Object.entries(object)) {
const current = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) paths.push(...leafPaths(value, current));
else paths.push(current);
}
return paths;
}
function successSentinel(spec) {
return `TIKU_LAUNCH_GATE_SUCCESS:${spec.id}`;
}
function validateLogArtifact(spec, item, artifactPath, collector) {
if (typeof spec.artifactSummary === 'function') return;
const sentinel = successSentinel(spec);
const content = fs.readFileSync(artifactPath, 'utf8');
if (!content.split(/\r?\n/).some(line => line.trim() === sentinel)) {
collector.block(`check.${spec.id}.artifact_success`, `${spec.label} artifact is missing its explicit success sentinel`, {
artifact: item.artifact,
expectedSentinel: sentinel,
});
} else {
collector.pass(`check.${spec.id}.artifact_success`, `${spec.label} artifact contains its explicit success sentinel`);
}
}
function isComparatorSpec(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
return ['eq', 'lt', 'lte', 'gt', 'gte', 'oneOf', 'truthy'].some(key => Object.prototype.hasOwnProperty.call(value, key));
}
function compareExpectedValue(actualValue, expectedValue) {
if (!isComparatorSpec(expectedValue)) {
if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) {
return `expected ${JSON.stringify(expectedValue)} but got ${JSON.stringify(actualValue)}`;
}
return '';
}
if (Object.prototype.hasOwnProperty.call(expectedValue, 'eq') && actualValue !== expectedValue.eq) {
return `expected ${JSON.stringify(expectedValue.eq)} but got ${JSON.stringify(actualValue)}`;
}
if (Object.prototype.hasOwnProperty.call(expectedValue, 'oneOf')) {
const choices = Array.isArray(expectedValue.oneOf) ? expectedValue.oneOf : [];
if (!choices.includes(actualValue)) {
return `expected one of ${JSON.stringify(choices)} but got ${JSON.stringify(actualValue)}`;
}
}
if (Object.prototype.hasOwnProperty.call(expectedValue, 'truthy')) {
const shouldBeTruthy = Boolean(expectedValue.truthy);
if (Boolean(actualValue) !== shouldBeTruthy) {
return `expected ${shouldBeTruthy ? 'truthy' : 'falsy'} but got ${JSON.stringify(actualValue)}`;
}
}
const numericChecks = [
['lt', (actual, expected) => actual < expected, '<'],
['lte', (actual, expected) => actual <= expected, '<='],
['gt', (actual, expected) => actual > expected, '>'],
['gte', (actual, expected) => actual >= expected, '>='],
];
for (const [key, predicate, label] of numericChecks) {
if (!Object.prototype.hasOwnProperty.call(expectedValue, key)) continue;
const actualNumber = Number(actualValue);
const expectedNumber = Number(expectedValue[key]);
if (!Number.isFinite(actualNumber) || !Number.isFinite(expectedNumber) || !predicate(actualNumber, expectedNumber)) {
return `expected ${label} ${expectedNumber} but got ${JSON.stringify(actualValue)}`;
}
}
return '';
}
function resultCollector() {
const checks = [];
function pass(id, message, details = {}) {
checks.push({ status: 'pass', id, message, details });
}
function warn(id, message, details = {}) {
checks.push({ status: 'warn', id, message, details });
}
function block(id, message, details = {}) {
checks.push({ status: 'blocker', id, message, details });
}
return { checks, pass, warn, block };
}
function validateTopLevel(evidence, collector) {
if (evidence.schemaVersion !== 1) {
collector.block('evidence.schema_version', 'Evidence schemaVersion must be 1', { actual: evidence.schemaVersion });
} else {
collector.pass('evidence.schema_version', 'Evidence schema version is supported');
}
if (evidence.environment !== 'production') {
collector.block('evidence.environment', 'Evidence environment must be production', { actual: evidence.environment });
} else {
collector.pass('evidence.environment', 'Evidence environment is production');
}
if (!evidence.commit || !/^[0-9a-f]{7,40}$/i.test(String(evidence.commit))) {
collector.block('evidence.commit', 'Evidence must include the reviewed deployment commit hash');
} else {
collector.pass('evidence.commit', 'Deployment commit is recorded', { commit: evidence.commit });
}
const currentCommit = String(process.env.DEPLOY_COMMIT_SHA || '').trim();
const deployReleaseRoot = String(process.env.DEPLOY_RELEASE_ROOT || '').trim();
if (currentCommit) {
const evidenceCommit = String(evidence.commit || '').toLowerCase();
const deployedCommit = currentCommit.toLowerCase();
if (!evidenceCommit.startsWith(deployedCommit) && !deployedCommit.startsWith(evidenceCommit)) {
collector.block('evidence.commit_match', 'Evidence commit does not match the commit being deployed', {
evidenceCommit: evidence.commit,
deployedCommit: currentCommit,
});
} else {
collector.pass('evidence.commit_match', 'Evidence commit matches the commit being deployed');
}
if (!deployReleaseRoot) {
collector.block('evidence.release_root', 'Deployment gate must provide DEPLOY_RELEASE_ROOT for artifact verification');
} else if (!fs.existsSync(deployReleaseRoot) || !fs.statSync(deployReleaseRoot).isDirectory()) {
collector.block('evidence.release_root', 'DEPLOY_RELEASE_ROOT must be an existing candidate release directory', {
deployReleaseRoot,
});
} else {
collector.pass('evidence.release_root', 'Candidate release directory is available for artifact verification', {
deployReleaseRoot,
});
}
}
const target = evidence.target || {};
const requiredTargets = ['apiBaseUrl', 'studentH5Url', 'tenantAdminH5Url', 'platformAdminH5Url'];
for (const key of requiredTargets) {
const value = String(target[key] || '').trim();
const failure = productionUrlFailure(value);
if (failure) {
collector.block(`target.${key}`, `${key} must be a real HTTPS production URL`, { value, failure });
} else {
collector.pass(`target.${key}`, `${key} is a valid production HTTPS URL`);
}
}
const releaseTargets = Array.isArray(evidence.releaseTargets) ? evidence.releaseTargets : ['h5'];
const invalidTargets = releaseTargets.filter(item => !['h5', 'weapp'].includes(item));
if (!releaseTargets.includes('h5') || invalidTargets.length) {
collector.block('evidence.release_targets', 'releaseTargets must include h5 and may optionally include weapp', { releaseTargets });
} else {
collector.pass('evidence.release_targets', 'Release targets are explicit', { releaseTargets });
}
}
function validateH5ReleaseManifest(artifactPath, collector) {
const deployReleaseRoot = String(process.env.DEPLOY_RELEASE_ROOT || '').trim();
if (!deployReleaseRoot) return;
let manifest;
try {
manifest = readJson(artifactPath);
} catch (error) {
collector.block('check.taro.h5-release-manifest.release_tree', 'H5 release manifest artifact must be valid JSON', {
artifactPath,
error: error.message,
});
return;
}
const failures = [];
for (const [portal, relativeDir] of h5ReleaseDirectories) {
const portalManifest = (Array.isArray(manifest.portals) ? manifest.portals : [])
.find(item => item?.portal === portal);
const expectedSha256 = String(portalManifest?.dist?.treeSha256 || '').toLowerCase();
const candidateDir = path.resolve(deployReleaseRoot, relativeDir);
if (!/^[0-9a-f]{64}$/.test(expectedSha256)) {
failures.push(`${portal}: manifest treeSha256 is missing or invalid`);
continue;
}
if (!fs.existsSync(candidateDir) || !fs.statSync(candidateDir).isDirectory()) {
failures.push(`${portal}: candidate directory is missing (${candidateDir})`);
continue;
}
const actual = hashArtifactDirectory(candidateDir);
if (actual.sha256 !== expectedSha256) {
failures.push(`${portal}: expected ${expectedSha256}, got ${actual.sha256}`);
}
}
if (failures.length > 0) {
collector.block('check.taro.h5-release-manifest.release_tree', 'Candidate H5 files do not match the reviewed release manifest', {
failures,
});
} else {
collector.pass('check.taro.h5-release-manifest.release_tree', 'Candidate H5 files match all reviewed release tree hashes');
}
}
async function fetchLiveResource(url, options) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.liveTimeoutMs);
try {
const fetchImpl = options.fetchImpl || fetch;
return await fetchImpl(url, {
redirect: 'follow',
signal: controller.signal,
headers: {
accept: '*/*',
'cache-control': 'no-cache',
'user-agent': 'tiku-production-launch-gate/1',
},
});
} finally {
clearTimeout(timeout);
}
}
async function liveResponseBytes(url, options, collector, id) {
let response;
try {
response = await fetchLiveResource(url, options);
} catch (error) {
collector.block(id, 'Live H5 request failed', { url, error: error instanceof Error ? error.message : String(error) });
return null;
}
const finalUrl = response.url || url;
const finalUrlFailure = productionUrlFailure(finalUrl);
if (finalUrlFailure) {
collector.block(id, 'Live H5 request redirected to a non-production URL', {
requestedUrl: url,
finalUrl,
failure: finalUrlFailure,
});
return null;
}
if (!response.ok) {
collector.block(id, 'Live H5 request returned a non-success status', { url, finalUrl, status: response.status });
return null;
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) {
collector.block(id, 'Live H5 response is empty', { url, finalUrl });
return null;
}
collector.pass(id, 'Live H5 resource returned a non-empty success response', {
url,
finalUrl,
status: response.status,
bytes: bytes.length,
});
return { bytes, response };
}
function releaseManifestPortals(evidence, options, collector) {
const liveH5 = evidence.liveH5 || {};
if (!liveH5.releaseManifestArtifact) return null;
const manifestPath = resolveArtifact(options.evidencePath, liveH5.releaseManifestArtifact);
if (!fs.existsSync(manifestPath) || !fs.statSync(manifestPath).isFile() || fs.statSync(manifestPath).size === 0) {
collector.block('live_h5.release_manifest', 'Live H5 release manifest artifact is missing or empty', {
artifact: liveH5.releaseManifestArtifact,
});
return null;
}
if (!validateHash(liveH5.releaseManifestSha256)) {
collector.block('live_h5.release_manifest_hash', 'Live H5 release manifest must record artifact SHA-256', {
artifact: liveH5.releaseManifestArtifact,
});
return null;
}
const actualHash = sha256File(manifestPath);
if (actualHash !== String(liveH5.releaseManifestSha256).toLowerCase()) {
collector.block('live_h5.release_manifest_hash', 'Live H5 release manifest hash does not match evidence', {
artifact: liveH5.releaseManifestArtifact,
expectedSha256: liveH5.releaseManifestSha256,
actualSha256: actualHash,
});
return null;
}
let manifest;
try {
manifest = readJson(manifestPath);
} catch (error) {
collector.block('live_h5.release_manifest', 'Live H5 release manifest is not valid JSON', {
artifact: liveH5.releaseManifestArtifact,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.portals)) {
collector.block('live_h5.release_manifest', 'Live H5 release manifest has an unsupported structure', {
artifact: liveH5.releaseManifestArtifact,
});
return null;
}
collector.pass('live_h5.release_manifest', 'Live H5 release manifest artifact is valid');
collector.pass('live_h5.release_manifest_hash', 'Live H5 release manifest hash matches evidence');
return manifest.portals;
}
function expectedPortalHashes(evidence, manifestPortals, portal, options, collector) {
const direct = portalEvidence(evidence.liveH5?.portals, portal) || {};
const manifestPortal = portalEvidence(manifestPortals, portal) || {};
let indexSha256 = direct.indexSha256 || manifestPortal.dist?.indexSha256 || '';
let appSha256 = direct.appSha256 || '';
let appPath = direct.appPath || '';
if (manifestPortal.dist) {
const deployReleaseRoot = String(options.deployReleaseRoot || process.env.DEPLOY_RELEASE_ROOT || '').trim();
const expectedRelativeDir = h5ReleaseDirectories.get(portal);
const candidateDir = expectedRelativeDir
? path.resolve(deployReleaseRoot || process.cwd(), expectedRelativeDir)
: '';
if (!candidateDir) {
collector.block(`live_h5.${portal}.candidate_files`, 'Live H5 portal has no fixed candidate directory mapping', {
portal,
});
return null;
}
const candidateIndexPath = path.join(candidateDir, 'index.html');
if (!fs.existsSync(candidateIndexPath) || !fs.statSync(candidateIndexPath).isFile()) {
collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest candidate index is unavailable for strict live verification', {
candidateIndexPath,
});
return null;
}
const candidateIndexSha256 = sha256File(candidateIndexPath);
if (indexSha256 && candidateIndexSha256 !== String(indexSha256).toLowerCase()) {
collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest index hash does not match the candidate file', {
candidateIndexPath,
expectedSha256: indexSha256,
actualSha256: candidateIndexSha256,
});
return null;
}
indexSha256 = candidateIndexSha256;
const candidateIndexHtml = fs.readFileSync(candidateIndexPath, 'utf8');
const candidateAppPath = appAssetPathFromIndex(candidateIndexHtml);
if (!candidateAppPath) {
collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest candidate index does not reference an app bundle', {
candidateIndexPath,
});
return null;
}
const candidateAppUrl = new URL(candidateAppPath, 'https://candidate.invalid/index.html');
const candidateAppFile = path.resolve(candidateDir, `.${candidateAppUrl.pathname}`);
const relativeCandidateApp = path.relative(candidateDir, candidateAppFile);
if (relativeCandidateApp.startsWith('..') || path.isAbsolute(relativeCandidateApp) || !fs.existsSync(candidateAppFile) || !fs.statSync(candidateAppFile).isFile()) {
collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest candidate app bundle is unavailable', {
candidateAppFile,
});
return null;
}
const candidateAppSha256 = sha256File(candidateAppFile);
if (appSha256 && candidateAppSha256 !== String(appSha256).toLowerCase()) {
collector.block(`live_h5.${portal}.candidate_files`, 'Direct app hash does not match the release manifest candidate bundle', {
candidateAppFile,
expectedSha256: appSha256,
actualSha256: candidateAppSha256,
});
return null;
}
appPath = candidateAppPath;
appSha256 = candidateAppSha256;
collector.pass(`live_h5.${portal}.candidate_files`, 'Release manifest is bound to the local candidate index and app bundle', {
candidateIndexPath,
candidateAppFile,
});
}
if (!validateHash(indexSha256) && !validateHash(appSha256)) {
collector.block(`live_h5.${portal}.candidate_hash`, 'Live H5 evidence must record a candidate index or app SHA-256', {
portal,
source: manifestPortals ? 'release-manifest-or-direct' : 'direct',
});
return null;
}
if (appSha256 && !validateHash(appSha256)) {
collector.block(`live_h5.${portal}.app_hash`, 'Live H5 appSha256 must be a SHA-256 value', { portal });
return null;
}
if (indexSha256 && !validateHash(indexSha256)) {
collector.block(`live_h5.${portal}.index_hash`, 'Live H5 indexSha256 must be a SHA-256 value', { portal });
return null;
}
return {
indexSha256: String(indexSha256).toLowerCase(),
appSha256: String(appSha256).toLowerCase(),
appPath,
};
}
async function validateLiveH5(evidence, options, collector) {
if (!options.verifyLiveH5) return;
const target = evidence.target || {};
const apiBaseUrl = String(target.apiBaseUrl || '').trim();
if (productionUrlFailure(apiBaseUrl)) {
collector.block('live_h5.target', 'Strict live H5 validation requires valid production target URLs');
return;
}
const manifestPortals = releaseManifestPortals(evidence, options, collector);
for (const spec of h5PortalTargets) {
const baseUrl = String(target[spec.targetKey] || '').trim();
if (productionUrlFailure(baseUrl)) {
collector.block(`live_h5.${spec.portal}.target`, 'Strict live H5 validation requires a valid portal URL', { baseUrl });
continue;
}
const expected = expectedPortalHashes(evidence, manifestPortals, spec.portal, options, collector);
const indexUrl = `${normalizedUrl(baseUrl)}/index.html`;
const runtimeUrl = `${normalizedUrl(baseUrl)}/runtime-config.json`;
const indexResult = await liveResponseBytes(indexUrl, options, collector, `live_h5.${spec.portal}.index_response`);
const runtimeResult = await liveResponseBytes(runtimeUrl, options, collector, `live_h5.${spec.portal}.runtime_response`);
if (!indexResult || !runtimeResult || !expected) continue;
const indexContentType = indexResult.response.headers.get('content-type') || '';
const indexHtml = indexResult.bytes.toString('utf8');
if (!/text\/html/i.test(indexContentType) || !/<div\s+id=["']app["']/i.test(indexHtml) || !/<script\b/i.test(indexHtml)) {
collector.block(`live_h5.${spec.portal}.index_contract`, 'Live H5 index response is not a deployable application shell', {
contentType: indexContentType,
});
} else {
collector.pass(`live_h5.${spec.portal}.index_contract`, 'Live H5 index response contains the application shell');
}
const actualIndexSha256 = sha256Buffer(indexResult.bytes);
if (expected.indexSha256) {
if (actualIndexSha256 !== expected.indexSha256) {
collector.block(`live_h5.${spec.portal}.index_hash`, 'Live H5 index does not match the release candidate', {
expectedSha256: expected.indexSha256,
actualSha256: actualIndexSha256,
});
} else {
collector.pass(`live_h5.${spec.portal}.index_hash`, 'Live H5 index matches the release candidate');
}
}
let runtimeConfig;
try {
runtimeConfig = JSON.parse(runtimeResult.bytes.toString('utf8'));
} catch (error) {
collector.block(`live_h5.${spec.portal}.runtime_json`, 'Live H5 runtime config is not valid JSON', {
error: error instanceof Error ? error.message : String(error),
});
continue;
}
const runtimePortal = runtimeConfig.portal || runtimeConfig.TARO_APP_PORTAL || '';
const runtimeApiBaseUrl = runtimeConfig.apiBaseUrl || runtimeConfig.TARO_APP_API_BASE_URL || '';
const runtimeApiFailure = productionUrlFailure(runtimeApiBaseUrl);
if (runtimePortal !== spec.portal) {
collector.block(`live_h5.${spec.portal}.runtime_portal`, 'Live H5 runtime portal does not match its release target', {
expectedPortal: spec.portal,
portal: runtimePortal,
});
} else {
collector.pass(`live_h5.${spec.portal}.runtime_portal`, 'Live H5 runtime portal matches its release target');
}
if (runtimeApiFailure) {
collector.block(`live_h5.${spec.portal}.runtime_api`, 'Live H5 runtime API URL is not production HTTPS', {
apiBaseUrl: runtimeApiBaseUrl,
failure: runtimeApiFailure,
});
} else if (normalizedUrl(runtimeApiBaseUrl) !== normalizedUrl(apiBaseUrl)) {
collector.block(`live_h5.${spec.portal}.runtime_api`, 'Live H5 runtime API URL does not match launch evidence', {
expectedApiBaseUrl: normalizedUrl(apiBaseUrl),
apiBaseUrl: normalizedUrl(runtimeApiBaseUrl),
});
} else {
collector.pass(`live_h5.${spec.portal}.runtime_api`, 'Live H5 runtime API URL matches launch evidence');
}
if (!expected.appSha256) continue;
const discoveredAppPath = appAssetPathFromIndex(indexHtml);
const appPath = expected.appPath || discoveredAppPath;
if (!appPath || (expected.appPath && discoveredAppPath && normalizeAssetPath(expected.appPath, indexUrl) !== normalizeAssetPath(discoveredAppPath, indexUrl))) {
collector.block(`live_h5.${spec.portal}.app_path`, 'Live H5 app asset path does not match evidence or cannot be discovered', {
expectedAppPath: expected.appPath,
discoveredAppPath,
});
continue;
}
const appUrl = new URL(appPath, indexUrl).href;
const appResult = await liveResponseBytes(appUrl, options, collector, `live_h5.${spec.portal}.app_response`);
if (!appResult) continue;
const actualAppSha256 = sha256Buffer(appResult.bytes);
if (actualAppSha256 !== expected.appSha256) {
collector.block(`live_h5.${spec.portal}.app_hash`, 'Live H5 app bundle does not match the release candidate', {
appPath: normalizeAssetPath(appPath, indexUrl),
expectedSha256: expected.appSha256,
actualSha256: actualAppSha256,
});
} else {
collector.pass(`live_h5.${spec.portal}.app_hash`, 'Live H5 app bundle matches the release candidate', {
appPath: normalizeAssetPath(appPath, indexUrl),
});
}
}
}
function validateGateCheck(spec, evidence, options, collector) {
const item = findById(evidence.checks, spec.id);
if (!item) {
collector.block(`check.${spec.id}`, `${spec.label} evidence is missing`);
return;
}
if (normalizeStatus(item.status) !== 'pass') {
collector.block(`check.${spec.id}.status`, `${spec.label} must have status=pass`, { status: item.status });
} else {
collector.pass(`check.${spec.id}.status`, `${spec.label} passed`);
}
if (spec.commandIncludes && !String(item.command || '').includes(spec.commandIncludes)) {
collector.block(`check.${spec.id}.command`, `${spec.label} command must include ${spec.commandIncludes}`, {
command: item.command || '',
});
} else {
collector.pass(`check.${spec.id}.command`, `${spec.label} command is recorded`);
}
const age = daysSince(item.completedAt);
if (!options.allowStale && age > options.maxAgeDays) {
collector.block(`check.${spec.id}.freshness`, `${spec.label} evidence is stale or missing completedAt`, {
completedAt: item.completedAt || '',
maxAgeDays: options.maxAgeDays,
});
} else {
collector.pass(`check.${spec.id}.freshness`, `${spec.label} evidence is fresh enough`, {
completedAt: item.completedAt || '',
});
}
if (!item.artifact) {
collector.block(`check.${spec.id}.artifact`, `${spec.label} must include a saved artifact/log path`);
} else {
const artifactPath = resolveArtifact(options.evidencePath, item.artifact);
if (!fs.existsSync(artifactPath)) {
collector.block(`check.${spec.id}.artifact`, `${spec.label} artifact file is missing`, { artifact: item.artifact });
} else {
const stat = fs.statSync(artifactPath);
if (!stat.isFile() || stat.size === 0) {
collector.block(`check.${spec.id}.artifact`, `${spec.label} artifact must be a non-empty file`, { artifact: item.artifact });
} else {
const actualSha256 = sha256File(artifactPath);
if (!/^[0-9a-f]{64}$/i.test(String(item.artifactSha256 || ''))) {
collector.block(`check.${spec.id}.artifact_hash`, `${spec.label} must record a SHA-256 for its artifact`, {
artifact: item.artifact,
});
} else if (actualSha256 !== String(item.artifactSha256).toLowerCase()) {
collector.block(`check.${spec.id}.artifact_hash`, `${spec.label} artifact hash does not match evidence`, {
artifact: item.artifact,
expectedSha256: item.artifactSha256,
actualSha256,
});
} else {
collector.pass(`check.${spec.id}.artifact`, `${spec.label} artifact is non-empty`, { artifact: item.artifact, bytes: stat.size });
collector.pass(`check.${spec.id}.artifact_hash`, `${spec.label} artifact hash matches evidence`);
if (spec.id === 'taro.h5-release-manifest') validateH5ReleaseManifest(artifactPath, collector);
validateStructuredArtifact(spec, item, artifactPath, evidence, options, collector);
validateLogArtifact(spec, item, artifactPath, collector);
}
}
}
}
const summaryFailures = compareSummary(item.summary || {}, spec.summary);
if (summaryFailures.length > 0) {
collector.block(`check.${spec.id}.summary`, `${spec.label} summary does not satisfy gate`, { failures: summaryFailures });
} else {
collector.pass(`check.${spec.id}.summary`, `${spec.label} summary satisfies gate`);
}
}
function validateAttestation(spec, evidence, options, collector) {
const item = findById(evidence.attestations, spec.id);
if (!item) {
collector.block(`attestation.${spec.id}`, `${spec.label} attestation is missing`);
return;
}
if (!isApproved(item.status)) {
collector.block(`attestation.${spec.id}.status`, `${spec.label} must be approved`, { status: item.status });
} else {
collector.pass(`attestation.${spec.id}.status`, `${spec.label} is approved`);
}
if (!String(item.approver || '').trim()) {
collector.block(`attestation.${spec.id}.approver`, `${spec.label} must include an approver`);
} else {
collector.pass(`attestation.${spec.id}.approver`, `${spec.label} has an approver`);
}
const age = daysSince(item.approvedAt);
if (!options.allowStale && age > options.maxAgeDays) {
collector.block(`attestation.${spec.id}.freshness`, `${spec.label} approval is stale or missing approvedAt`, {
approvedAt: item.approvedAt || '',
maxAgeDays: options.maxAgeDays,
});
} else {
collector.pass(`attestation.${spec.id}.freshness`, `${spec.label} approval is fresh enough`, {
approvedAt: item.approvedAt || '',
});
}
}
async function validateEvidence(evidence, options) {
const collector = resultCollector();
validateTopLevel(evidence, collector);
for (const spec of gateChecks) validateGateCheck(spec, evidence, options, collector);
for (const spec of requiredAttestations) validateAttestation(spec, evidence, options, collector);
if (Array.isArray(evidence.releaseTargets) && evidence.releaseTargets.includes('weapp')) {
for (const spec of weappGateChecks) validateGateCheck(spec, evidence, options, collector);
for (const spec of weappAttestations) validateAttestation(spec, evidence, options, collector);
}
await validateLiveH5(evidence, options, collector);
return collector.checks;
}
function summarize(checks) {
return checks.reduce(
(summary, item) => {
summary[item.status] += 1;
return summary;
},
{ blocker: 0, warn: 0, pass: 0 },
);
}
function printHuman(options, evidence, checks) {
const summary = summarize(checks);
console.log('Production launch gate');
console.log(`Evidence: ${options.evidencePath}`);
console.log(`Host: ${os.hostname()}`);
console.log(`Target API: ${evidence.target?.apiBaseUrl || '(missing)'}`);
console.log(`Summary: ${summary.blocker} blocker(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
for (const item of checks) {
const marker = item.status === 'blocker' ? 'BLOCK' : item.status === 'warn' ? 'WARN ' : 'PASS ';
console.log(`[${marker}] ${item.id}: ${item.message}`);
}
}
async function main() {
const options = parseArgs(process.argv);
if (!fs.existsSync(options.evidencePath)) {
const message = `Evidence file not found: ${options.evidencePath}`;
if (options.json) console.log(JSON.stringify({ summary: { blocker: 1, warn: 0, pass: 0 }, checks: [{ status: 'blocker', id: 'evidence.file', message }] }, null, 2));
else console.error(`${message}\nCreate it from docs/refactor/production-launch-evidence.template.json after running real production checks.`);
process.exitCode = 1;
return;
}
const evidence = readJson(options.evidencePath);
const checks = await validateEvidence(evidence, options);
const summary = summarize(checks);
if (options.json) console.log(JSON.stringify({ summary, checks }, null, 2));
else printHuman(options, evidence, checks);
if (summary.blocker > 0) process.exitCode = 1;
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
main().catch(error => {
const message = error instanceof Error ? error.message : String(error);
if (process.argv.includes('--json')) {
console.log(JSON.stringify({
summary: { blocker: 1, warn: 0, pass: 0 },
checks: [{ status: 'blocker', id: 'gate.unhandled_error', message }],
}, null, 2));
} else {
console.error(`Production launch gate failed: ${message}`);
}
process.exitCode = 1;
});
}
export {
gateChecks,
h5PortalTargets,
parseArgs,
productionUrlFailure,
requiredAttestations,
validateEvidence,
weappGateChecks,
weappAttestations,
};