forked from wangziqi/gongxue-base
1633 lines
62 KiB
JavaScript
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,
|
|
};
|