完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)
这个提交包含在:
@@ -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();
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户