完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)
这个提交包含在:
+22
-5
@@ -3,21 +3,31 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
echo "=== [1/5] Process Contract Gate ==="
|
||||
echo "=== [1/7] Process Contract Gate ==="
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
node scripts/qa/check-process-contract.mjs --baseline
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "=== [2/5] Server Prisma Validate ==="
|
||||
echo "=== [2/7] Governance + Sensitive Content ==="
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
node scripts/qa/check-trellis-governance.mjs
|
||||
node scripts/qa/check-sensitive-content.mjs
|
||||
bash -n scripts/qa/run-live-smoke-readiness.sh
|
||||
bash -n deploy/trialrun/deploy.sh
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "=== [3/7] Server Prisma Validate ==="
|
||||
(
|
||||
cd "$ROOT_DIR/server"
|
||||
npm run prisma:validate
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "=== [3/5] Server Static Checks ==="
|
||||
echo "=== [4/7] Server Static Checks ==="
|
||||
(
|
||||
cd "$ROOT_DIR/server"
|
||||
node --check src/routes/reimbursements.js
|
||||
@@ -69,7 +79,14 @@ echo "=== [3/5] Server Static Checks ==="
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "=== [4/5] Client Typecheck + Build ==="
|
||||
echo "=== [5/7] Server Unit Tests ==="
|
||||
(
|
||||
cd "$ROOT_DIR/server"
|
||||
npm test
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "=== [6/7] Client Typecheck + Build ==="
|
||||
(
|
||||
cd "$ROOT_DIR/client"
|
||||
npm run typecheck
|
||||
@@ -77,7 +94,7 @@ echo "=== [4/5] Client Typecheck + Build ==="
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "=== [5/5] Feature Matrix Consistency ==="
|
||||
echo "=== [7/7] Feature Matrix Consistency ==="
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
python3 scripts/validate-feature-matrix.py
|
||||
|
||||
@@ -3,6 +3,22 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
|
||||
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
|
||||
const expectedBuildShaInput = String(process.env.LIVE_SMOKE_EXPECTED_BUILD_SHA ?? "").trim();
|
||||
|
||||
function normalizeBuildSha(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (normalized === "unknown") {
|
||||
return "unknown";
|
||||
}
|
||||
return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized.slice(0, 12) : null;
|
||||
}
|
||||
|
||||
function buildShaMatches(expected, observed) {
|
||||
if (!expected || !observed || observed === "unknown") {
|
||||
return null;
|
||||
}
|
||||
return expected.startsWith(observed) || observed.startsWith(expected);
|
||||
}
|
||||
|
||||
function present(key) {
|
||||
return Boolean(process.env[key]?.trim());
|
||||
@@ -108,7 +124,7 @@ function addMissing(checks) {
|
||||
checks.missing.push("ocrOrEmployeeCredentialReady");
|
||||
}
|
||||
if (!checks.liveOcr.invoiceSampleReady) {
|
||||
checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR 中的 PDF 样票");
|
||||
checks.missing.push("invoiceSampleReady");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,12 +137,40 @@ async function main() {
|
||||
const ready = await fetchJson(`${baseUrl}/ready`);
|
||||
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
||||
const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`);
|
||||
const findings = checks.missing.map((code) => ({ code, severity: "manual_or_config" }));
|
||||
if (!ready.ok) findings.push({ code: "publicReady", severity: "blocker" });
|
||||
const expectedBuildSha = normalizeBuildSha(expectedBuildShaInput);
|
||||
const observedBuildSha = normalizeBuildSha(
|
||||
ready.json?.data?.buildSha ?? ready.json?.error?.details?.buildSha,
|
||||
);
|
||||
const buildMatches = buildShaMatches(expectedBuildSha, observedBuildSha);
|
||||
if (ready.ok && (!observedBuildSha || observedBuildSha === "unknown")) {
|
||||
findings.push({ code: "publicBuildShaUnavailable", severity: "manual_or_config" });
|
||||
} else if (ready.ok && expectedBuildSha && buildMatches === false) {
|
||||
findings.push({ code: "publicBuildShaMismatch", severity: "manual_or_config" });
|
||||
}
|
||||
if (expectedBuildShaInput && !expectedBuildSha) {
|
||||
findings.push({ code: "expectedBuildShaInvalid", severity: "manual_or_config" });
|
||||
}
|
||||
if (!wecomConfig.ok) findings.push({ code: "wecomConfigReady", severity: "blocker" });
|
||||
if (!authCapabilities.ok) findings.push({ code: "authCapabilitiesReady", severity: "blocker" });
|
||||
const status = findings.some((finding) => finding.severity === "blocker")
|
||||
? "FAIL"
|
||||
: findings.length
|
||||
? "PASS_WITH_FINDINGS"
|
||||
: "PASS";
|
||||
const result = {
|
||||
status,
|
||||
findingCount: findings.length,
|
||||
findings,
|
||||
checkedAt: new Date().toISOString(),
|
||||
...checks,
|
||||
publicReady: {
|
||||
ok: ready.ok,
|
||||
status: ready.status,
|
||||
expectedBuildSha,
|
||||
observedBuildSha,
|
||||
buildShaMatches: buildMatches,
|
||||
},
|
||||
wecomConfig: {
|
||||
ok: wecomConfig.ok,
|
||||
@@ -135,7 +179,7 @@ async function main() {
|
||||
entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null,
|
||||
oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null,
|
||||
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
||||
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
|
||||
loginUrlReady: Boolean(wecomConfig.json?.data?.loginUrl),
|
||||
},
|
||||
authCapabilities: {
|
||||
ok: authCapabilities.ok,
|
||||
@@ -150,8 +194,10 @@ async function main() {
|
||||
if (jsonOnly) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`[live-smoke-env] status=${result.status} findings=${result.findingCount}`);
|
||||
console.log(`[live-smoke-env] base=${result.baseUrl}`);
|
||||
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
|
||||
console.log(`[live-smoke-env] buildShaObserved=${result.publicReady.observedBuildSha ?? "unavailable"} match=${result.publicReady.buildShaMatches ?? "not_checked"}`);
|
||||
console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`);
|
||||
console.log(`[live-smoke-env] authCapabilities=${result.authCapabilities.ok} emailDelivery=${result.authCapabilities.emailDeliveryReady}`);
|
||||
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
|
||||
@@ -161,7 +207,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok || !result.authCapabilities.ok)) {
|
||||
if (strict && result.status !== "PASS") {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+116
-65
@@ -1,78 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { PrismaClient } from "../../server/node_modules/@prisma/client/index.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function parseThresholds() {
|
||||
const arg = process.argv.find((item) => item.startsWith("--thresholds="));
|
||||
export function parseThresholds(argv = process.argv.slice(2)) {
|
||||
const arg = argv.find((item) => item.startsWith("--thresholds="));
|
||||
const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10";
|
||||
return raw
|
||||
.split(",")
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((item) => Number.isFinite(item) && item > 0)
|
||||
.sort((a, b) => a - b);
|
||||
return Array.from(new Set(
|
||||
raw
|
||||
.split(",")
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((item) => Number.isFinite(item) && item > 0)
|
||||
.map((item) => Math.floor(item)),
|
||||
)).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function minutesAgo(minutes) {
|
||||
return new Date(Date.now() - minutes * 60_000);
|
||||
function minutesAgo(minutes, now) {
|
||||
return new Date(now.getTime() - minutes * 60_000);
|
||||
}
|
||||
|
||||
export async function buildPendingOcrSummary({
|
||||
databaseReady,
|
||||
thresholds,
|
||||
countPending,
|
||||
now = new Date(),
|
||||
}) {
|
||||
if (!databaseReady) {
|
||||
return {
|
||||
mode: "pending-ocr-readiness",
|
||||
status: "SKIP",
|
||||
findingCount: 1,
|
||||
findings: [{ code: "database_not_configured", severity: "manual_or_config" }],
|
||||
thresholds: [],
|
||||
checkedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const thresholdResults = [];
|
||||
for (const thresholdMinutes of thresholds) {
|
||||
const count = Number(await countPending({
|
||||
status: "pending_ocr",
|
||||
created_at: { lte: minutesAgo(thresholdMinutes, now) },
|
||||
}));
|
||||
thresholdResults.push({
|
||||
thresholdMinutes,
|
||||
count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const findingCount = thresholdResults.reduce((maximum, item) => Math.max(maximum, item.count), 0);
|
||||
return {
|
||||
mode: "pending-ocr-readiness",
|
||||
status: findingCount > 0 ? "PASS_WITH_FINDINGS" : "PASS",
|
||||
findingCount,
|
||||
findings: findingCount > 0
|
||||
? [{ code: "pending_ocr_detected", severity: "finding", count: findingCount }]
|
||||
: [],
|
||||
thresholds: thresholdResults,
|
||||
checkedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executePendingOcrCheck({
|
||||
databaseReady = Boolean(process.env.DATABASE_URL?.trim()),
|
||||
thresholds = parseThresholds(),
|
||||
} = {}) {
|
||||
if (!databaseReady) {
|
||||
return buildPendingOcrSummary({ databaseReady: false, thresholds, countPending: async () => 0 });
|
||||
}
|
||||
|
||||
const prismaClientModule = await import("../../server/src/prismaClient.js");
|
||||
const createPrismaClient = prismaClientModule.createPrismaClient
|
||||
?? prismaClientModule.default?.createPrismaClient;
|
||||
if (typeof createPrismaClient !== "function") {
|
||||
throw new Error("Prisma client factory is unavailable");
|
||||
}
|
||||
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
try {
|
||||
return await buildPendingOcrSummary({
|
||||
databaseReady: true,
|
||||
thresholds,
|
||||
countPending: (where) => prisma.invoicePool.count({ where }),
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(summary, jsonOnly) {
|
||||
if (jsonOnly) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`[pending-ocr][${summary.status}] findings=${summary.findingCount}`);
|
||||
for (const item of summary.thresholds) {
|
||||
console.log(`[pending-ocr] thresholdMinutes=${item.thresholdMinutes} count=${item.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.log("[pending-ocr][SKIP] missing DATABASE_URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const thresholds = parseThresholds();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const jsonOnly = process.argv.includes("--json");
|
||||
try {
|
||||
const summary = [];
|
||||
for (const minutes of thresholds) {
|
||||
const where = {
|
||||
status: "pending_ocr",
|
||||
created_at: { lte: minutesAgo(minutes) },
|
||||
};
|
||||
const count = await prisma.invoicePool.count({ where });
|
||||
const rows = await prisma.invoicePool.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "asc" },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_requested_at: true,
|
||||
ocr_last_error: true,
|
||||
ocr_locked_at: true,
|
||||
ocr_lock_owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
summary.push({
|
||||
thresholdMinutes: minutes,
|
||||
count,
|
||||
sampleLimit: 50,
|
||||
items: rows.map((item) => ({
|
||||
id: item.id,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
attempts: item.ocr_attempts ?? 0,
|
||||
lastRequestedAt: item.ocr_last_requested_at?.toISOString?.() ?? null,
|
||||
lastError: item.ocr_last_error ?? null,
|
||||
lockedAt: item.ocr_locked_at?.toISOString?.() ?? null,
|
||||
lockOwner: item.ocr_lock_owner ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, thresholds: summary }, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
const summary = await executePendingOcrCheck();
|
||||
printSummary(summary, jsonOnly);
|
||||
} catch {
|
||||
const summary = {
|
||||
mode: "pending-ocr-readiness",
|
||||
status: "FAIL",
|
||||
findingCount: 1,
|
||||
findings: [{ code: "pending_ocr_check_failed", severity: "blocker" }],
|
||||
thresholds: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
printSummary(summary, jsonOnly);
|
||||
console.error("[pending-ocr][FAIL] database check failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[pending-ocr][FAIL] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ import process from "node:process";
|
||||
const baseUrl = normalizeBaseUrl(process.argv[2] ?? process.env.PUBLIC_BASE_URL ?? process.env.LIVE_SMOKE_BASE_URL ?? "https://oa.hyinda.com");
|
||||
const focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/;
|
||||
const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"];
|
||||
const expectedBuildSha = normalizeBuildSha(process.env.PUBLIC_EXPECTED_BUILD_SHA);
|
||||
const forbiddenFullBuildSha = normalizeBuildSha(process.env.PUBLIC_FORBIDDEN_BUILD_SHA, { keepFull: true });
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeBuildSha(value, { keepFull = false } = {}) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{7,64}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return keepFull ? normalized : normalized.slice(0, 12);
|
||||
}
|
||||
|
||||
function toAssetPath(value) {
|
||||
if (value.startsWith("/")) {
|
||||
return value;
|
||||
@@ -27,6 +37,7 @@ async function fetchText(path) {
|
||||
async function collectAssets() {
|
||||
const seen = new Set();
|
||||
const queue = [];
|
||||
const sources = new Map();
|
||||
const html = await fetchText("/");
|
||||
|
||||
for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) {
|
||||
@@ -40,6 +51,7 @@ async function collectAssets() {
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const assetPath = queue[index];
|
||||
const source = await fetchText(assetPath);
|
||||
sources.set(assetPath, source);
|
||||
for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) {
|
||||
const nextPath = toAssetPath(match[1]);
|
||||
if (!seen.has(nextPath)) {
|
||||
@@ -49,28 +61,56 @@ async function collectAssets() {
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(seen).sort();
|
||||
return {
|
||||
assets: Array.from(seen).sort(),
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
const assets = await collectAssets();
|
||||
const { assets, sources } = await collectAssets();
|
||||
const findings = [];
|
||||
let expectedBuildShaFound = expectedBuildSha === null;
|
||||
let forbiddenFullBuildShaFound = false;
|
||||
|
||||
for (const assetPath of assets) {
|
||||
const source = sources.get(assetPath) ?? "";
|
||||
if (expectedBuildSha && source.toLowerCase().includes(expectedBuildSha)) {
|
||||
expectedBuildShaFound = true;
|
||||
}
|
||||
if (
|
||||
forbiddenFullBuildSha
|
||||
&& forbiddenFullBuildSha !== expectedBuildSha
|
||||
&& source.toLowerCase().includes(forbiddenFullBuildSha)
|
||||
) {
|
||||
forbiddenFullBuildShaFound = true;
|
||||
}
|
||||
|
||||
if (!focusedAssetPattern.test(assetPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = await fetchText(assetPath);
|
||||
for (const term of forbiddenTerms) {
|
||||
if (source.includes(term)) {
|
||||
findings.push({ assetPath, term });
|
||||
findings.push({ assetPath, category: "forbidden_copy", term });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} findings=${findings.length}`);
|
||||
if (!expectedBuildShaFound) {
|
||||
findings.push({ assetPath: "public-bundle", category: "build_sha_missing" });
|
||||
}
|
||||
if (forbiddenFullBuildShaFound) {
|
||||
findings.push({ assetPath: "public-bundle", category: "full_build_sha_exposed" });
|
||||
}
|
||||
|
||||
const buildMarkerStatus = expectedBuildSha === null
|
||||
? "NOT_CHECKED"
|
||||
: expectedBuildShaFound && !forbiddenFullBuildShaFound
|
||||
? "PASS"
|
||||
: "FAIL";
|
||||
console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} buildMarker=${buildMarkerStatus} findings=${findings.length}`);
|
||||
for (const finding of findings) {
|
||||
console.log(`ERROR ${finding.assetPath} contains ${finding.term}`);
|
||||
console.log(`ERROR ${finding.assetPath} category=${finding.category}`);
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const DEFAULT_SCAN_FILES = [
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"server/README.md",
|
||||
"client/README.md",
|
||||
"server/.env.example",
|
||||
"docker-compose.yml",
|
||||
"deploy/trialrun/deploy.sh",
|
||||
"deploy/trialrun/.env.example",
|
||||
"deploy/trialrun/runbook.md",
|
||||
"deploy/trialrun/checklist.md",
|
||||
"deploy/trialrun/setup-server.sh",
|
||||
"docs/Agent开发交付实践.md",
|
||||
"docs/文档索引.md",
|
||||
"docs/文档事实源状态矩阵.md",
|
||||
"docs/产品设计规则.md",
|
||||
"docs/报销发票附件规则.md",
|
||||
"docs/试运行与自动化验证手册.md",
|
||||
];
|
||||
const CURRENT_DOCUMENT_REGISTRY = "docs/文档事实源状态矩阵.md";
|
||||
|
||||
const TEXT_PATTERNS = [
|
||||
{
|
||||
category: "private_key",
|
||||
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||
},
|
||||
{
|
||||
category: "bearer_value",
|
||||
pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/i,
|
||||
},
|
||||
{
|
||||
category: "credential_in_url",
|
||||
pattern: /postgres(?:ql)?:\/\/[^\s:/<>]+:[^\s@<>]+@/i,
|
||||
},
|
||||
{
|
||||
category: "assigned_secret",
|
||||
pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API_KEY)[A-Z0-9_]*\b\s*[:=]\s*["']?(?!\s*(?:<|\$\{|\*|""|''|REDACTED|example))[A-Za-z0-9._/+=:-]{8,}/,
|
||||
},
|
||||
{
|
||||
category: "personal_workspace_path",
|
||||
pattern: /\/(?:home|Users)\/(?!<)[^/\s`'"()]+\/[A-Za-z0-9._/ -]+/,
|
||||
},
|
||||
{
|
||||
category: "email_account",
|
||||
pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
|
||||
},
|
||||
{
|
||||
category: "phone_account",
|
||||
pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/,
|
||||
},
|
||||
];
|
||||
|
||||
function isPublicIpv4(value) {
|
||||
const parts = value.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((item) => !Number.isInteger(item) || item < 0 || item > 255)) {
|
||||
return false;
|
||||
}
|
||||
if (parts[0] === 10 || parts[0] === 127 || parts[0] === 0) return false;
|
||||
if (parts[0] === 169 && parts[1] === 254) return false;
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
|
||||
if (parts[0] === 192 && parts[1] === 168) return false;
|
||||
if (parts[0] >= 224) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scanSensitiveText(text, fileRef) {
|
||||
const findings = [];
|
||||
String(text).split(/\r?\n/).forEach((line, index) => {
|
||||
for (const { category, pattern } of TEXT_PATTERNS) {
|
||||
if (category === "credential_in_url" && (/\$\{/.test(line) || /<[^>]+>/.test(line))) {
|
||||
continue;
|
||||
}
|
||||
if (pattern.test(line)) {
|
||||
findings.push({ fileRef, line: index + 1, category });
|
||||
}
|
||||
}
|
||||
const ipv4Values = line.match(/(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)/g) || [];
|
||||
if (ipv4Values.some(isPublicIpv4)) {
|
||||
findings.push({ fileRef, line: index + 1, category: "public_ipv4" });
|
||||
}
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function extractCurrentDocumentFiles(matrixText) {
|
||||
const files = new Set();
|
||||
for (const line of String(matrixText).split(/\r?\n/)) {
|
||||
if (!line.startsWith("|")) {
|
||||
continue;
|
||||
}
|
||||
const cells = line
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((cell) => cell.trim());
|
||||
if (!cells.some((cell) => cell === "`CURRENT`" || cell === "CURRENT")) {
|
||||
continue;
|
||||
}
|
||||
const contractCell = cells[1] ?? "";
|
||||
for (const match of contractCell.matchAll(/`(docs\/[^`|]+\.(?:md|csv))`/g)) {
|
||||
const normalized = path.posix.normalize(match[1]);
|
||||
if (normalized.startsWith("docs/") && !normalized.includes("..")) {
|
||||
files.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...files].sort();
|
||||
}
|
||||
|
||||
export function resolveDefaultScanFiles(rootDir) {
|
||||
const registryPath = path.join(rootDir, CURRENT_DOCUMENT_REGISTRY);
|
||||
const currentDocuments = existsSync(registryPath)
|
||||
? extractCurrentDocumentFiles(readFileSync(registryPath, "utf8"))
|
||||
: [];
|
||||
return [...new Set([...DEFAULT_SCAN_FILES, ...currentDocuments])];
|
||||
}
|
||||
|
||||
export function scanSensitiveContent({ rootDir, files }) {
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
const scanFiles = files ?? resolveDefaultScanFiles(rootDir);
|
||||
for (const fileRef of scanFiles) {
|
||||
const file = path.join(rootDir, fileRef);
|
||||
if (!existsSync(file)) {
|
||||
continue;
|
||||
}
|
||||
filesScanned += 1;
|
||||
findings.push(...scanSensitiveText(readFileSync(file, "utf8"), fileRef));
|
||||
}
|
||||
return {
|
||||
status: findings.length ? "FAIL" : "PASS",
|
||||
findingCount: findings.length,
|
||||
filesScanned,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
const result = scanSensitiveContent({ rootDir });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (result.findingCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
@@ -87,17 +88,33 @@ function parseJsonl(relativePath) {
|
||||
});
|
||||
}
|
||||
|
||||
function checkContextManifest(result, taskDir, action) {
|
||||
export function contextManifestPolicy(dispatchMode) {
|
||||
const inlineDispatch = dispatchMode === "inline";
|
||||
return {
|
||||
required: !inlineDispatch,
|
||||
requireRealEntries: !inlineDispatch,
|
||||
rejectSeedRows: !inlineDispatch,
|
||||
};
|
||||
}
|
||||
|
||||
function checkContextManifest(result, taskDir, action, dispatchMode) {
|
||||
const relativePath = `${taskDir}/${action}.jsonl`;
|
||||
const policy = contextManifestPolicy(dispatchMode);
|
||||
if (!exists(relativePath)) {
|
||||
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
|
||||
if (policy.required) {
|
||||
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
|
||||
} else {
|
||||
pass(result, `task.${action}_inline_optional`, `${taskDir} uses inline dispatch; ${action}.jsonl is optional`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entries = parseJsonl(relativePath);
|
||||
let realEntryCount = 0;
|
||||
for (const { value, line } of entries) {
|
||||
if (value._example) {
|
||||
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
|
||||
if (policy.rejectSeedRows) {
|
||||
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof value.file !== "string" || !value.file.trim()) {
|
||||
@@ -117,8 +134,10 @@ function checkContextManifest(result, taskDir, action) {
|
||||
}
|
||||
realEntryCount += 1;
|
||||
}
|
||||
if (realEntryCount === 0) {
|
||||
if (realEntryCount === 0 && policy.requireRealEntries) {
|
||||
fail(result, `task.${action}_empty`, `${relativePath} must contain at least one real context entry`);
|
||||
} else if (realEntryCount === 0) {
|
||||
pass(result, `task.${action}_inline_optional`, `${relativePath} has no injected context because dispatch_mode=inline`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +206,8 @@ function checkCoreFiles(result) {
|
||||
|
||||
function checkTaskContracts(result) {
|
||||
const taskDirs = listTaskDirs();
|
||||
const config = exists(".trellis/config.yaml") ? readText(".trellis/config.yaml") : "";
|
||||
const dispatchMode = /dispatch_mode:\s*sub-agent\b/.test(config) ? "sub-agent" : "inline";
|
||||
if (taskDirs.length === 0) {
|
||||
fail(result, "tasks.none", "no .trellis/tasks/* directories found");
|
||||
return;
|
||||
@@ -227,8 +248,8 @@ function checkTaskContracts(result) {
|
||||
if (complexTaskPattern.test(complexText) && !exists(`${dir}/design.md`) && !exists(`${dir}/implement.md`)) {
|
||||
fail(result, "task.complex_design", `${dir} complex task must include design.md or implement.md`);
|
||||
}
|
||||
checkContextManifest(result, dir, "implement");
|
||||
checkContextManifest(result, dir, "check");
|
||||
checkContextManifest(result, dir, "implement", dispatchMode);
|
||||
checkContextManifest(result, dir, "check", dispatchMode);
|
||||
}
|
||||
if (result.status === "PASS") pass(result, "task_contracts", `${taskDirs.length} task directories have Trellis contracts`);
|
||||
}
|
||||
@@ -296,16 +317,23 @@ function checkSensitiveData(result) {
|
||||
if (result.status === "PASS") pass(result, "sensitive_scan", `${scanFiles.length} Trellis files scanned`);
|
||||
}
|
||||
|
||||
const result = createResult();
|
||||
try {
|
||||
checkCoreFiles(result);
|
||||
checkTaskContracts(result);
|
||||
checkDocsRegistration(result);
|
||||
checkRemovedProjectSkillReferences(result);
|
||||
checkSensitiveData(result);
|
||||
} catch (error) {
|
||||
fail(result, "exception", error instanceof Error ? error.message : String(error));
|
||||
export function runGovernanceChecks() {
|
||||
const result = createResult();
|
||||
try {
|
||||
checkCoreFiles(result);
|
||||
checkTaskContracts(result);
|
||||
checkDocsRegistration(result);
|
||||
checkRemovedProjectSkillReferences(result);
|
||||
checkSensitiveData(result);
|
||||
} catch (error) {
|
||||
fail(result, "exception", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.status === "PASS" ? 0 : 1);
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
const result = runGovernanceChecks();
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.status === "PASS" ? 0 : 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const READINESS_STEP_NAMES = [
|
||||
"environment",
|
||||
"publicReady",
|
||||
"publicBundle",
|
||||
"pendingOcr",
|
||||
"liveSmoke",
|
||||
"liveOcr",
|
||||
"privacy",
|
||||
];
|
||||
|
||||
const CHILD_STEP_NAMES = ["environment", "pendingOcr", "liveSmoke", "liveOcr"];
|
||||
const BLOCKING_STATUS_PATTERN = /FAIL|ERROR|BLOCKED/i;
|
||||
const MANUAL_STATUS_PATTERN = /SKIP|MISSING|PAUSED|PARTIAL|PASS_WITH_FINDINGS/i;
|
||||
|
||||
export function toReportRef(rootDir, file) {
|
||||
const absolute = path.resolve(file);
|
||||
const relative = path.relative(path.resolve(rootDir), absolute);
|
||||
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
return path.basename(absolute);
|
||||
}
|
||||
|
||||
function safeBaseUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ""));
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFindingCount(status, value) {
|
||||
const parsed = Number(value || 0);
|
||||
const count = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
||||
return status === "PASS" ? count : Math.max(1, count);
|
||||
}
|
||||
|
||||
function normalizeStep(name, input = {}) {
|
||||
const status = String(input.status || "MISSING").toUpperCase();
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
kind: input.kind === "required" ? "required" : "finding_allowed",
|
||||
code: Number(input.code || 0),
|
||||
findingCount: normalizeFindingCount(status, input.findingCount),
|
||||
detail: String(input.detail || "").slice(0, 240),
|
||||
logRef: input.logRef || null,
|
||||
artifactRef: input.artifactRef || null,
|
||||
checkedAt: input.checkedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateReadinessSteps(stepInputs) {
|
||||
const steps = Object.fromEntries(
|
||||
READINESS_STEP_NAMES.map((name) => [name, normalizeStep(name, stepInputs?.[name])]),
|
||||
);
|
||||
const findings = [];
|
||||
|
||||
for (const [name, step] of Object.entries(steps)) {
|
||||
if (step.status === "PASS" && step.findingCount === 0) {
|
||||
continue;
|
||||
}
|
||||
const blocking = step.kind === "required" && BLOCKING_STATUS_PATTERN.test(step.status);
|
||||
findings.push({
|
||||
source: name,
|
||||
status: step.status,
|
||||
severity: blocking
|
||||
? "blocker"
|
||||
: MANUAL_STATUS_PATTERN.test(step.status)
|
||||
? "manual_or_config"
|
||||
: "finding",
|
||||
findingCount: step.findingCount,
|
||||
reportRef: step.artifactRef || step.logRef,
|
||||
});
|
||||
}
|
||||
|
||||
const findingCount = findings.reduce((total, finding) => total + Math.max(1, finding.findingCount), 0);
|
||||
const status = findings.some((finding) => finding.severity === "blocker")
|
||||
? "FAIL"
|
||||
: findingCount > 0
|
||||
? "PASS_WITH_FINDINGS"
|
||||
: "PASS";
|
||||
|
||||
return { status, findingCount, findings, steps };
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
if (!existsSync(file)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentCommit(rootDir) {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReadinessSummary({
|
||||
rootDir,
|
||||
reportDir,
|
||||
baseUrl,
|
||||
commit = currentCommit(rootDir),
|
||||
checkedAt = new Date().toISOString(),
|
||||
}) {
|
||||
const stepInputs = Object.fromEntries(
|
||||
READINESS_STEP_NAMES.map((name) => [
|
||||
name,
|
||||
readJson(path.join(reportDir, `${name}.step.json`)) || {
|
||||
status: "MISSING",
|
||||
kind: "required",
|
||||
logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const evaluated = evaluateReadinessSteps(stepInputs);
|
||||
const environmentReport = readJson(path.join(reportDir, "environment.json"));
|
||||
const childReports = Object.fromEntries(
|
||||
CHILD_STEP_NAMES.map((name) => {
|
||||
const step = evaluated.steps[name];
|
||||
return [name, {
|
||||
status: step.status,
|
||||
findingCount: step.findingCount,
|
||||
reportRef: step.artifactRef || step.logRef,
|
||||
}];
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
issueId: process.env.QA_ISSUE_ID?.trim() || null,
|
||||
mode: "live-smoke-readiness",
|
||||
status: evaluated.status,
|
||||
conclusion: evaluated.status,
|
||||
baseUrl: safeBaseUrl(baseUrl),
|
||||
commit,
|
||||
sourceCommit: commit,
|
||||
expectedBuildSha: environmentReport?.publicReady?.expectedBuildSha ?? null,
|
||||
observedBuildSha: environmentReport?.publicReady?.observedBuildSha ?? null,
|
||||
buildShaMatches: environmentReport?.publicReady?.buildShaMatches ?? null,
|
||||
checkedAt,
|
||||
reportRef: toReportRef(rootDir, reportDir),
|
||||
findingCount: evaluated.findingCount,
|
||||
findings: evaluated.findings,
|
||||
childReports,
|
||||
steps: evaluated.steps,
|
||||
boundaries: {
|
||||
readOnlyChecksOnly: true,
|
||||
noCredentialValues: true,
|
||||
noInvoiceSamplesEmbedded: true,
|
||||
noAbsoluteWorkspacePaths: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile }) {
|
||||
if (!READINESS_STEP_NAMES.includes(name)) {
|
||||
throw new Error(`unknown readiness step: ${name}`);
|
||||
}
|
||||
const step = normalizeStep(name, {
|
||||
status,
|
||||
kind,
|
||||
code,
|
||||
findingCount,
|
||||
detail,
|
||||
logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)),
|
||||
artifactRef: artifactFile ? toReportRef(rootDir, artifactFile) : null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
mkdirSync(reportDir, { recursive: true });
|
||||
writeFileSync(path.join(reportDir, `${name}.step.json`), `${JSON.stringify(step, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function collectReportFiles(directory) {
|
||||
if (!existsSync(directory)) {
|
||||
return [];
|
||||
}
|
||||
const files = [];
|
||||
for (const entry of readdirSync(directory)) {
|
||||
const file = path.join(directory, entry);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...collectReportFiles(file));
|
||||
} else if (/\.(json|log|txt)$/i.test(entry) && entry !== "privacy-findings.json") {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function scanReadinessPrivacy({ rootDir, reportDir }) {
|
||||
const patterns = [
|
||||
{ category: "workspace_path", pattern: new RegExp(escapeRegExp(path.resolve(rootDir)), "g") },
|
||||
{ category: "secret_field", pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|ENCODING_AES_KEY)[A-Z0-9_]*\b/g },
|
||||
{ category: "bearer_value", pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/gi },
|
||||
{ category: "private_key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
||||
{ category: "email", pattern: /[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}/g },
|
||||
{ category: "phone", pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
|
||||
];
|
||||
const findings = [];
|
||||
|
||||
for (const file of collectReportFiles(reportDir)) {
|
||||
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
for (const { category, pattern } of patterns) {
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(line)) {
|
||||
findings.push({
|
||||
fileRef: toReportRef(rootDir, file),
|
||||
line: index + 1,
|
||||
category,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const report = {
|
||||
status: findings.length ? "FAIL" : "PASS",
|
||||
findingCount: findings.length,
|
||||
findings,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(path.join(reportDir, "privacy-findings.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
return report;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: live-smoke-readiness-report.mjs <write-step|summarize|privacy> ...");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (command === "write-step") {
|
||||
const [rootDir, reportDir, name, status, kind, code, findingCount, detail = "", artifactFile = ""] = args;
|
||||
writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile });
|
||||
return;
|
||||
}
|
||||
if (command === "summarize") {
|
||||
const [rootDir, reportDir, baseUrl] = args;
|
||||
const summary = buildReadinessSummary({ rootDir, reportDir, baseUrl });
|
||||
writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify({ status: summary.status, findingCount: summary.findingCount }));
|
||||
return;
|
||||
}
|
||||
if (command === "privacy") {
|
||||
const [rootDir, reportDir] = args;
|
||||
const report = scanReadinessPrivacy({ rootDir, reportDir });
|
||||
console.log(JSON.stringify({ status: report.status, findingCount: report.findingCount }));
|
||||
if (report.findingCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
usage();
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
可执行文件 -> 普通文件
+151
-40
@@ -1,30 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -u -o pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
REPORT_DIR="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-readiness-$(date +%Y%m%d-%H%M%S)}"
|
||||
REPORT_TOOL="$ROOT_DIR/scripts/qa/live-smoke-readiness-report.mjs"
|
||||
STATUS=0
|
||||
SOURCE_COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || printf 'unknown')"
|
||||
SOURCE_SHORT_COMMIT="${SOURCE_COMMIT:0:12}"
|
||||
EXPECTED_BUILD_SHA="${LIVE_SMOKE_EXPECTED_BUILD_SHA:-$SOURCE_SHORT_COMMIT}"
|
||||
FORBIDDEN_FULL_BUILD_SHA=""
|
||||
if [[ "$SOURCE_COMMIT" =~ ^[0-9a-fA-F]{40,64}$ && "${EXPECTED_BUILD_SHA:0:12}" == "$SOURCE_SHORT_COMMIT" ]]; then
|
||||
FORBIDDEN_FULL_BUILD_SHA="$SOURCE_COMMIT"
|
||||
fi
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
write_summary() {
|
||||
local exit_code="$?"
|
||||
local status="PASS"
|
||||
if [[ "$exit_code" -ne 0 ]]; then
|
||||
status="FAIL"
|
||||
fi
|
||||
QA_REPORT_DIR="$REPORT_DIR" \
|
||||
QA_ISSUE_ID="${QA_ISSUE_ID:-}" \
|
||||
QA_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
QA_MODE="live-smoke-readiness" \
|
||||
QA_STATUS="$status" \
|
||||
QA_SUMMARY_NOTES="公网 /ready、public bundle、pending OCR、live smoke 和真实 OCR 可用性检查;缺少凭据或样票时按脚本规则记录 SKIP。" \
|
||||
node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true
|
||||
return "$exit_code"
|
||||
write_step() {
|
||||
local name="$1"
|
||||
local status="$2"
|
||||
local kind="$3"
|
||||
local code="$4"
|
||||
local finding_count="$5"
|
||||
local detail="$6"
|
||||
local artifact_file="${7:-}"
|
||||
node "$REPORT_TOOL" write-step \
|
||||
"$ROOT_DIR" "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file"
|
||||
}
|
||||
|
||||
trap write_summary EXIT
|
||||
apply_step_status() {
|
||||
local status="$1"
|
||||
local kind="$2"
|
||||
if [[ "$kind" == "required" && "$status" =~ FAIL|ERROR|BLOCKED ]]; then
|
||||
STATUS=1
|
||||
fi
|
||||
}
|
||||
|
||||
run_command_step() {
|
||||
local name="$1"
|
||||
local kind="$2"
|
||||
local detail="$3"
|
||||
shift 3
|
||||
local log_file="$REPORT_DIR/${name}.log"
|
||||
local code=0
|
||||
local status="PASS"
|
||||
local finding_count=0
|
||||
|
||||
echo ">>> [live-readiness] $name"
|
||||
if "$@" > >(tee "$log_file") 2>&1; then
|
||||
echo "[live-readiness][PASS] $name"
|
||||
else
|
||||
code="$?"
|
||||
status="FAIL"
|
||||
finding_count=1
|
||||
echo "[live-readiness][FAIL] $name exit=$code"
|
||||
fi
|
||||
write_step "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$log_file"
|
||||
apply_step_status "$status" "$kind"
|
||||
}
|
||||
|
||||
run_json_step() {
|
||||
local name="$1"
|
||||
local kind="$2"
|
||||
local detail="$3"
|
||||
local artifact_file="$4"
|
||||
shift 4
|
||||
local log_file="$REPORT_DIR/${name}.log"
|
||||
local code=0
|
||||
|
||||
echo ">>> [live-readiness] $name"
|
||||
if "$@" >"$artifact_file" 2> >(tee "$log_file" >&2); then
|
||||
code=0
|
||||
else
|
||||
code="$?"
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const status = String(value.status || "FAIL").toUpperCase();
|
||||
const count = Number(value.findingCount || 0);
|
||||
process.stdout.write(`${status}\t${Number.isFinite(count) ? count : 1}`);
|
||||
} catch {
|
||||
process.stdout.write("FAIL\t1");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
' "$artifact_file")" || true
|
||||
local status="FAIL"
|
||||
local finding_count=1
|
||||
IFS=$'\t' read -r status finding_count <<<"$parsed"
|
||||
if [[ "$code" -ne 0 && ! "$status" =~ FAIL|ERROR|BLOCKED ]]; then
|
||||
status="FAIL"
|
||||
finding_count=1
|
||||
fi
|
||||
|
||||
echo "[live-readiness][$status] $name findings=$finding_count"
|
||||
write_step "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file"
|
||||
apply_step_status "$status" "$kind"
|
||||
}
|
||||
|
||||
write_skip_step() {
|
||||
local name="$1"
|
||||
local detail="$2"
|
||||
local log_file="$REPORT_DIR/${name}.log"
|
||||
echo "[live-readiness][SKIP] $name:$detail" | tee "$log_file"
|
||||
write_step "$name" "SKIP" "finding_allowed" 0 1 "$detail" "$log_file"
|
||||
}
|
||||
|
||||
has_live_smoke_credentials() {
|
||||
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
||||
@@ -54,39 +138,66 @@ has_live_ocr_sample() {
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ">>> [live-readiness] 环境与公网入口检查"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$REPORT_DIR/env.json"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs"
|
||||
run_json_step \
|
||||
"environment" \
|
||||
"required" \
|
||||
"public endpoints and safe readiness labels" \
|
||||
"$REPORT_DIR/environment.json" \
|
||||
env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_EXPECTED_BUILD_SHA="$EXPECTED_BUILD_SHA" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json
|
||||
|
||||
echo ">>> [live-readiness] /ready: $PUBLIC_BASE_URL"
|
||||
curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
|
||||
run_command_step \
|
||||
"publicReady" \
|
||||
"required" \
|
||||
"public readiness endpoint" \
|
||||
curl -fsS "$PUBLIC_BASE_URL/ready"
|
||||
|
||||
echo ">>> [live-readiness] 公网资源文案扫描"
|
||||
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt"
|
||||
run_command_step \
|
||||
"publicBundle" \
|
||||
"required" \
|
||||
"public bundle copy and build marker scan" \
|
||||
env PUBLIC_EXPECTED_BUILD_SHA="$EXPECTED_BUILD_SHA" PUBLIC_FORBIDDEN_BUILD_SHA="$FORBIDDEN_FULL_BUILD_SHA" node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL"
|
||||
|
||||
echo ">>> [live-readiness] pending OCR 扫描"
|
||||
node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 | tee "$REPORT_DIR/pending-ocr.txt"
|
||||
run_json_step \
|
||||
"pendingOcr" \
|
||||
"required" \
|
||||
"pending OCR count summary" \
|
||||
"$REPORT_DIR/pending-ocr.json" \
|
||||
node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 --json
|
||||
|
||||
if has_live_smoke_credentials; then
|
||||
echo ">>> [live-readiness] 执行公网 live smoke"
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
|
||||
run_command_step \
|
||||
"liveSmoke" \
|
||||
"required" \
|
||||
"authenticated public smoke" \
|
||||
env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live
|
||||
else
|
||||
echo ">>> [live-readiness] SKIP live smoke:缺少完整 LIVE_SMOKE_* 凭据" | tee "$REPORT_DIR/live-smoke.txt"
|
||||
write_skip_step "liveSmoke" "authenticated smoke prerequisites are incomplete"
|
||||
fi
|
||||
|
||||
if has_live_ocr_credentials && has_live_ocr_sample; then
|
||||
echo ">>> [live-readiness] 执行真实 OCR 样本复核"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
PLAYWRIGHT_MANUAL=1 \
|
||||
PLAYWRIGHT_OCR_LIVE=1 \
|
||||
PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" \
|
||||
E2E_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list
|
||||
) | tee "$REPORT_DIR/live-ocr.txt"
|
||||
run_command_step \
|
||||
"liveOcr" \
|
||||
"required" \
|
||||
"live OCR sample verification" \
|
||||
bash -c 'cd "$1" && PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" E2E_BASE_URL="$2" npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list' _ "$CLIENT_DIR" "$PUBLIC_BASE_URL"
|
||||
else
|
||||
echo ">>> [live-readiness] SKIP 真实 OCR:缺少 smoke 员工凭据或 PLAYWRIGHT_OCR_LIVE_FILE/PLAYWRIGHT_OCR_LIVE_DIR" | tee "$REPORT_DIR/live-ocr.txt"
|
||||
write_skip_step "liveOcr" "OCR credentials or PDF sample are incomplete"
|
||||
fi
|
||||
|
||||
echo "[live-readiness][PASS] readiness 检查完成"
|
||||
echo "[live-readiness] report=$REPORT_DIR"
|
||||
if node "$REPORT_TOOL" privacy "$ROOT_DIR" "$REPORT_DIR" >"$REPORT_DIR/privacy.log" 2>&1; then
|
||||
write_step "privacy" "PASS" "required" 0 0 "readiness report privacy scan" "$REPORT_DIR/privacy-findings.json"
|
||||
else
|
||||
write_step "privacy" "FAIL" "required" 1 1 "redacted privacy findings detected" "$REPORT_DIR/privacy-findings.json"
|
||||
STATUS=1
|
||||
fi
|
||||
|
||||
if ! node "$REPORT_TOOL" summarize "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL"; then
|
||||
STATUS=1
|
||||
fi
|
||||
|
||||
summary_status="$(node -e 'const s=require(process.argv[1]); process.stdout.write(String(s.status || "UNKNOWN"))' "$REPORT_DIR/summary.json" 2>/dev/null || printf 'UNKNOWN')"
|
||||
summary_ref="$(node -e 'const s=require(process.argv[1]); process.stdout.write(String(s.reportRef || "summary unavailable"))' "$REPORT_DIR/summary.json" 2>/dev/null || printf 'summary unavailable')"
|
||||
echo "[live-readiness][$summary_status] readiness 检查完成"
|
||||
echo "[live-readiness] report=$summary_ref"
|
||||
|
||||
exit "$STATUS"
|
||||
|
||||
在新工单中引用
屏蔽一个用户