482 行
18 KiB
Bash
可执行文件
482 行
18 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -u -o pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
|
REPORT_DIR="${OALAUNCH_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/oa-final-launch-readiness-$(date +%Y%m%d-%H%M%S)}"
|
|
SUMMARY_TXT="$REPORT_DIR/summary.txt"
|
|
STATUS=0
|
|
|
|
mkdir -p "$REPORT_DIR"
|
|
: >"$SUMMARY_TXT"
|
|
|
|
log() {
|
|
printf '%s\n' "$*" | tee -a "$SUMMARY_TXT"
|
|
}
|
|
|
|
run_step() {
|
|
local name="$1"
|
|
shift
|
|
local log_file="$REPORT_DIR/${name}.log"
|
|
|
|
log ">>> [oalaunch1] $name"
|
|
if "$@" > >(tee "$log_file") 2>&1; then
|
|
log "[oalaunch1][PASS] $name"
|
|
else
|
|
local code="$?"
|
|
log "[oalaunch1][FAIL] $name exit=$code"
|
|
STATUS=1
|
|
fi
|
|
}
|
|
|
|
write_manual_gates() {
|
|
cat >"$REPORT_DIR/manual-gates.md" <<'EOF'
|
|
# OALAUNCH1 最终启用人工门禁
|
|
|
|
## Day 7 Go/No-Go
|
|
- A:接受 `PASS_OA_LAUNCH_READY`,进入小团队正式启用。
|
|
- B:接受 `PASS_WITH_FINDINGS`,但必须指定 owner、处理日期和接受理由。
|
|
- C:暂停启用,先整改。
|
|
|
|
## 阻断处理
|
|
- A:当天新建修复合同并修复。
|
|
- B:临时人工绕行,记录风险、补录责任人和补录方式。
|
|
- C:暂停对应流程。
|
|
|
|
## 备份恢复演练
|
|
- A:接受恢复演练结果,进入 Go/No-Go。
|
|
- B:修复后重跑恢复演练。
|
|
- C:暂停正式启用。
|
|
|
|
## 必须补齐的业务证据
|
|
- 至少 1 条真实报销 PDF OCR -> 审批 -> 付款批次 -> 银行回单 -> 已付款 -> AP 导出。
|
|
- 至少 1 条采购/费用审批并关联项目或合同。
|
|
- 至少 1 条合同台账含归档位置和权限检查。
|
|
- 资产台账 ASSET1D 已完成后,补真实资产责任人或保管人抽查。
|
|
EOF
|
|
}
|
|
|
|
run_optional_live_checks() {
|
|
if [[ "${OALAUNCH_RUN_OAOPS1_DAILY:-0}" == "1" ]]; then
|
|
run_step "oaops1-daily-readonly" env \
|
|
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
|
OAOPS1_REPORT_DIR="$REPORT_DIR/oaops1-daily-readonly" \
|
|
bash "$ROOT_DIR/scripts/qa/run-oa-real-use-daily.sh"
|
|
else
|
|
log "[oalaunch1][SKIP] oaops1-daily-readonly:默认不跑公网巡检;需要时设置 OALAUNCH_RUN_OAOPS1_DAILY=1"
|
|
fi
|
|
}
|
|
|
|
write_summary_json() {
|
|
node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { execSync } = require("node:child_process");
|
|
|
|
const [rootDir, reportDir, baseURL, statusInput] = process.argv.slice(2);
|
|
const qaFlowDir = path.join(rootDir, "artifacts", "qa-flow");
|
|
const oalaunchCsv = path.join(rootDir, "issues", "2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv");
|
|
const oaopsCsv = path.join(rootDir, "issues", "2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv");
|
|
const assetCsv = path.join(rootDir, "issues", "2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv");
|
|
|
|
function readJson(file) {
|
|
if (!file || !fs.existsSync(file)) return null;
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCsv(text) {
|
|
const rows = [];
|
|
let field = "";
|
|
let row = [];
|
|
let inQuotes = false;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const ch = text[index];
|
|
const next = text[index + 1];
|
|
if (ch === '"') {
|
|
if (inQuotes && next === '"') {
|
|
field += '"';
|
|
index += 1;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
}
|
|
} else if (ch === "," && !inQuotes) {
|
|
row.push(field);
|
|
field = "";
|
|
} else if ((ch === "\n" || ch === "\r") && !inQuotes) {
|
|
if (ch === "\r" && next === "\n") index += 1;
|
|
row.push(field);
|
|
if (row.some((item) => item !== "")) rows.push(row);
|
|
row = [];
|
|
field = "";
|
|
} else {
|
|
field += ch;
|
|
}
|
|
}
|
|
if (field || row.length) {
|
|
row.push(field);
|
|
if (row.some((item) => item !== "")) rows.push(row);
|
|
}
|
|
if (!rows.length) return [];
|
|
const header = rows[0];
|
|
return rows.slice(1).map((items) => Object.fromEntries(header.map((key, index) => [key, items[index] ?? ""])));
|
|
}
|
|
|
|
function readCsvRows(file) {
|
|
if (!fs.existsSync(file)) return [];
|
|
return parseCsv(fs.readFileSync(file, "utf8"));
|
|
}
|
|
|
|
function rowById(rows, id) {
|
|
return rows.find((row) => row.ID === id) || null;
|
|
}
|
|
|
|
function isDone(row) {
|
|
return row?.Dev_Status === "DONE" && row?.Review1_Status === "DONE" && row?.Regression_Status === "DONE";
|
|
}
|
|
|
|
function statuses(rows, ids) {
|
|
return Object.fromEntries(ids.map((id) => {
|
|
const row = rowById(rows, id);
|
|
return [id, {
|
|
title: row?.Title || null,
|
|
dev: row?.Dev_Status || "MISSING",
|
|
review: row?.Review1_Status || "MISSING",
|
|
regression: row?.Regression_Status || "MISSING",
|
|
done: isDone(row),
|
|
}];
|
|
}));
|
|
}
|
|
|
|
function allPrefixDone(rows, prefix) {
|
|
const matching = rows.filter((row) => String(row.ID || "").startsWith(prefix));
|
|
return matching.length > 0 && matching.every(isDone);
|
|
}
|
|
|
|
function reportRef(file) {
|
|
if (!file) return null;
|
|
const absolute = path.resolve(file);
|
|
const relative = path.relative(rootDir, absolute);
|
|
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
return relative;
|
|
}
|
|
return path.basename(absolute);
|
|
}
|
|
|
|
function latestSummary(prefixes) {
|
|
if (!fs.existsSync(qaFlowDir)) return null;
|
|
const entries = fs.readdirSync(qaFlowDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.filter((entry) => prefixes.some((prefix) => entry.name.startsWith(prefix)))
|
|
.map((entry) => {
|
|
const dir = path.join(qaFlowDir, entry.name);
|
|
const summaryPath = path.join(dir, "summary.json");
|
|
const stat = fs.existsSync(summaryPath) ? fs.statSync(summaryPath) : fs.statSync(dir);
|
|
return { name: entry.name, dir, summaryPath, mtimeMs: stat.mtimeMs };
|
|
})
|
|
.filter((entry) => fs.existsSync(entry.summaryPath))
|
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
if (!entries.length) return null;
|
|
const latest = entries[0];
|
|
const json = readJson(latest.summaryPath) || {};
|
|
return {
|
|
name: latest.name,
|
|
reportRef: reportRef(latest.summaryPath),
|
|
status: json.status || json.conclusion || "UNKNOWN",
|
|
findingCount: Number(json.findingCount || 0),
|
|
};
|
|
}
|
|
|
|
function resolveEvidenceRef(value) {
|
|
if (!value) return null;
|
|
const file = path.isAbsolute(value) ? value : path.join(rootDir, value);
|
|
return {
|
|
provided: true,
|
|
exists: fs.existsSync(file),
|
|
ref: reportRef(file),
|
|
};
|
|
}
|
|
|
|
function parseDayFromName(name) {
|
|
const match = String(name).match(/20\d{6}/);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
function normalizeDay(value) {
|
|
const text = String(value || "").trim();
|
|
const match = text.match(/^(20\d{2})-?(\d{2})-?(\d{2})$/);
|
|
return match ? `${match[1]}${match[2]}${match[3]}` : null;
|
|
}
|
|
|
|
function addDays(day, offset) {
|
|
const year = Number(day.slice(0, 4));
|
|
const month = Number(day.slice(4, 6)) - 1;
|
|
const date = Number(day.slice(6, 8));
|
|
const value = new Date(Date.UTC(year, month, date + offset));
|
|
return `${value.getUTCFullYear()}${String(value.getUTCMonth() + 1).padStart(2, "0")}${String(value.getUTCDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function collectDailyReports(prefixes) {
|
|
if (!fs.existsSync(qaFlowDir)) return {};
|
|
const byDay = {};
|
|
for (const entry of fs.readdirSync(qaFlowDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (!prefixes.some((prefix) => entry.name.startsWith(prefix))) continue;
|
|
const day = parseDayFromName(entry.name);
|
|
if (!day) continue;
|
|
const summaryPath = path.join(qaFlowDir, entry.name, "summary.json");
|
|
if (!fs.existsSync(summaryPath)) continue;
|
|
const json = readJson(summaryPath) || {};
|
|
const stat = fs.statSync(summaryPath);
|
|
byDay[day] ||= [];
|
|
byDay[day].push({
|
|
name: entry.name,
|
|
reportRef: reportRef(summaryPath),
|
|
status: json.status || json.conclusion || "UNKNOWN",
|
|
findingCount: Number(json.findingCount || 0),
|
|
mtimeMs: stat.mtimeMs,
|
|
});
|
|
}
|
|
for (const reports of Object.values(byDay)) {
|
|
reports.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
}
|
|
return byDay;
|
|
}
|
|
|
|
function summarizeStabilization(prefixes) {
|
|
const startDay = normalizeDay(process.env.OALAUNCH_STABILIZATION_START_DATE || "");
|
|
const reportsByDay = collectDailyReports(prefixes);
|
|
const observedDays = Object.keys(reportsByDay).sort();
|
|
if (!startDay) {
|
|
return {
|
|
status: "MISSING_START_DATE",
|
|
startDay: null,
|
|
requiredDays: [],
|
|
observedDays,
|
|
dayReports: {},
|
|
};
|
|
}
|
|
|
|
const requiredDays = Array.from({ length: 7 }, (_, index) => addDays(startDay, index));
|
|
const dayReports = {};
|
|
const missingDays = [];
|
|
const blockedDays = [];
|
|
const findingDays = [];
|
|
for (const day of requiredDays) {
|
|
const latest = reportsByDay[day]?.[0] || null;
|
|
dayReports[day] = latest ? {
|
|
name: latest.name,
|
|
reportRef: latest.reportRef,
|
|
status: latest.status,
|
|
findingCount: latest.findingCount,
|
|
} : null;
|
|
if (!latest) {
|
|
missingDays.push(day);
|
|
} else if (/BLOCKED|FAIL|ERROR/i.test(latest.status)) {
|
|
blockedDays.push(day);
|
|
} else if (latest.findingCount > 0 || /FINDING/i.test(latest.status)) {
|
|
findingDays.push(day);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: missingDays.length ? "MISSING_DAYS" : blockedDays.length ? "HAS_BLOCKERS" : findingDays.length ? "HAS_FINDINGS" : "PASS",
|
|
startDay,
|
|
requiredDays,
|
|
observedDays,
|
|
missingDays,
|
|
blockedDays,
|
|
findingDays,
|
|
dayReports,
|
|
};
|
|
}
|
|
|
|
let commit = "unknown";
|
|
try {
|
|
commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
} catch {}
|
|
|
|
const oaopsRows = readCsvRows(oaopsCsv);
|
|
const assetRows = readCsvRows(assetCsv);
|
|
const oalaunchRows = readCsvRows(oalaunchCsv);
|
|
const oaopsRequired = ["OAOPS1-3", "OAOPS1-4", "OAOPS1-5", "OAOPS1-6"];
|
|
const oalaunchRequired = ["OALAUNCH1-0", "OALAUNCH1-1", "OALAUNCH1-2", "OALAUNCH1-3", "OALAUNCH1-4", "OALAUNCH1-5", "OALAUNCH1-6", "OALAUNCH1-7", "OALAUNCH1-8"];
|
|
const oaopsStatuses = statuses(oaopsRows, oaopsRequired);
|
|
const oalaunchStatuses = statuses(oalaunchRows, oalaunchRequired);
|
|
const assetDone = allPrefixDone(assetRows, "ASSET1D-");
|
|
const latestOaops = latestSummary(["oa-real-use-daily-", "oaops1-daily-", "oaops1-daily-after-throttle-"]);
|
|
const latestAssetTrial = latestSummary(["asset1d-public-trial-"]);
|
|
const latestAssetImport = latestSummary(["asset-redacted-index-import-live-"]);
|
|
|
|
const backupEvidence = resolveEvidenceRef(process.env.OALAUNCH_BACKUP_EVIDENCE || "");
|
|
const permissionEvidence = resolveEvidenceRef(process.env.OALAUNCH_PERMISSION_EVIDENCE || "");
|
|
const businessEvidence = resolveEvidenceRef(process.env.OALAUNCH_BUSINESS_EVIDENCE || "");
|
|
const manualAccept = String(process.env.OALAUNCH_MANUAL_ACCEPT || "").trim();
|
|
const stabilization = summarizeStabilization(["oa-real-use-daily-", "oaops1-daily-", "oaops1-daily-after-throttle-"]);
|
|
|
|
const openGates = [];
|
|
const findings = [];
|
|
|
|
for (const [id, state] of Object.entries(oaopsStatuses)) {
|
|
if (!state.done) openGates.push({ gate: id, reason: "OAOPS1 真实业务闭环仍未完成", state });
|
|
}
|
|
for (const [id, state] of Object.entries(oalaunchStatuses)) {
|
|
if (!state.done) openGates.push({ gate: id, reason: "OALAUNCH1 本轮合同/脚本/事实源/门禁尚未全部回填 DONE", state });
|
|
}
|
|
if (!assetDone) openGates.push({ gate: "ASSET1D", reason: "资产台账部署与试录未全量完成" });
|
|
if (!latestOaops) openGates.push({ gate: "OAOPS1_DAILY_REPORT", reason: "未找到 OAOPS1 每日巡检 summary.json" });
|
|
if (latestOaops && /BLOCKED|FAIL|ERROR/i.test(latestOaops.status)) findings.push({ source: "latest_oaops1_daily", severity: "blocker", status: latestOaops.status, reportRef: latestOaops.reportRef });
|
|
if (latestOaops && latestOaops.findingCount > 0) findings.push({ source: "latest_oaops1_daily", severity: "finding", status: latestOaops.status, findingCount: latestOaops.findingCount, reportRef: latestOaops.reportRef });
|
|
if (stabilization.status !== "PASS") {
|
|
const reason = stabilization.status === "MISSING_START_DATE"
|
|
? "缺 7 天稳定化起始日期;设置 OALAUNCH_STABILIZATION_START_DATE=YYYY-MM-DD 后按连续 7 天检查"
|
|
: stabilization.status === "MISSING_DAYS"
|
|
? "7 天稳定化连续报告不完整"
|
|
: stabilization.status === "HAS_BLOCKERS"
|
|
? "7 天稳定化窗口内存在阻断报告"
|
|
: "7 天稳定化窗口内存在 finding,必须按 PASS_WITH_FINDINGS 人工接受";
|
|
openGates.push({ gate: "SEVEN_DAY_STABILIZATION", reason, stabilization });
|
|
}
|
|
if (stabilization.status === "HAS_BLOCKERS") findings.push({ source: "seven_day_stabilization", severity: "blocker", blockedDays: stabilization.blockedDays });
|
|
if (stabilization.status === "HAS_FINDINGS") findings.push({ source: "seven_day_stabilization", severity: "finding", findingDays: stabilization.findingDays });
|
|
if (!backupEvidence?.exists) openGates.push({ gate: "BACKUP_RESTORE_DRILL", reason: "缺备份恢复演练或影子库恢复证据;可用 OALAUNCH_BACKUP_EVIDENCE 指定脱敏证据路径" });
|
|
if (!permissionEvidence?.exists) openGates.push({ gate: "PERMISSION_MATRIX", reason: "缺最终权限矩阵负例/角色入口证据;可用 OALAUNCH_PERMISSION_EVIDENCE 指定脱敏证据路径" });
|
|
if (!businessEvidence?.exists && Object.values(oaopsStatuses).some((state) => !state.done)) {
|
|
openGates.push({ gate: "BUSINESS_ACCEPTANCE_EVIDENCE", reason: "缺真实业务闭环证据;可用 OALAUNCH_BUSINESS_EVIDENCE 指定脱敏证据路径" });
|
|
}
|
|
if (!/^PASS_OA_LAUNCH_READY$|^PASS_WITH_FINDINGS$/i.test(manualAccept)) {
|
|
openGates.push({ gate: "DAY7_GO_NO_GO", reason: "缺 Day 7 人工 Go/No-Go 接受结论;可用 OALAUNCH_MANUAL_ACCEPT=PASS_OA_LAUNCH_READY 或 PASS_WITH_FINDINGS 指定" });
|
|
}
|
|
if (findings.length > 0 && manualAccept !== "PASS_WITH_FINDINGS") {
|
|
openGates.push({ gate: "FINDING_ACCEPTANCE_REQUIRED", reason: "存在 finding 时必须由 Day 7 人工选择 PASS_WITH_FINDINGS,并指定 owner、日期和接受理由" });
|
|
}
|
|
|
|
let status = "PAUSED_MANUAL_CONFIRMATION";
|
|
if (Number(statusInput || 0) !== 0 || findings.some((item) => item.severity === "blocker")) {
|
|
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
|
} else if (openGates.length > 0) {
|
|
status = "PAUSED_MANUAL_CONFIRMATION";
|
|
} else if (findings.length > 0 && manualAccept === "PASS_WITH_FINDINGS") {
|
|
status = "PASS_WITH_FINDINGS";
|
|
} else if (manualAccept === "PASS_OA_LAUNCH_READY") {
|
|
status = "PASS_OA_LAUNCH_READY";
|
|
} else if (manualAccept === "PASS_WITH_FINDINGS") {
|
|
status = "PASS_WITH_FINDINGS";
|
|
}
|
|
|
|
const summary = {
|
|
issueId: "OALAUNCH1",
|
|
mode: "oa-final-launch-readiness",
|
|
status,
|
|
conclusion: status,
|
|
baseURL,
|
|
commit,
|
|
checkedAt: new Date().toISOString(),
|
|
reportRef: reportRef(reportDir),
|
|
scope: {
|
|
launchScope: "P0 + light P1",
|
|
stabilizationDaysRequired: 7,
|
|
smallTeamFormalUse: true,
|
|
wecomHrAttendanceStatus: "frozen_at_wecom_owned_oa_archive_audit",
|
|
},
|
|
evidence: {
|
|
latestOaops,
|
|
latestAssetTrial,
|
|
latestAssetImport,
|
|
backupEvidence,
|
|
permissionEvidence,
|
|
businessEvidence,
|
|
stabilization,
|
|
},
|
|
issueStatus: {
|
|
oaopsRequired: oaopsStatuses,
|
|
oalaunchRequired,
|
|
oalaunchStatuses,
|
|
assetDone,
|
|
},
|
|
gates: {
|
|
openCount: openGates.length,
|
|
openGates,
|
|
findings,
|
|
},
|
|
manualGates: {
|
|
day7GoNoGo: [
|
|
"A:接受 PASS_OA_LAUNCH_READY,进入小团队正式启用。",
|
|
"B:接受 PASS_WITH_FINDINGS,但指定 owner 和完成日期后启用。",
|
|
"C:暂停启用,先整改。",
|
|
],
|
|
blockerHandling: [
|
|
"A:当天新建修复合同并修复。",
|
|
"B:临时人工绕行,记录风险。",
|
|
"C:暂停对应流程。",
|
|
],
|
|
backupDrill: [
|
|
"A:接受恢复演练结果,进入 Go/No-Go。",
|
|
"B:修复后重跑恢复演练。",
|
|
"C:暂停正式启用。",
|
|
],
|
|
},
|
|
boundaries: {
|
|
defaultReadOnly: true,
|
|
noBusinessWritesByThisScript: true,
|
|
noWeComWrites: true,
|
|
noPaymentExecution: true,
|
|
noDbResetOrRestore: true,
|
|
frozenItems: [
|
|
"image_ocr",
|
|
"complex_bpm",
|
|
"budget_control",
|
|
"erp_write",
|
|
"bank_statement_import",
|
|
"real_payment_provider",
|
|
"wecom_hr_deep_mirror",
|
|
"emergency_takeover",
|
|
"recovery_writeback",
|
|
"ruleops",
|
|
],
|
|
},
|
|
privacy: "Report keeps paths/status/counts only; no passwords, tokens, Secrets, full accounts, contract bodies, bank statements, raw invoice images, or raw payloads.",
|
|
};
|
|
|
|
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
console.log(`[oalaunch1] summary=${reportRef(path.join(reportDir, "summary.json"))}`);
|
|
if (status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") process.exitCode = 1;
|
|
NODE
|
|
}
|
|
|
|
run_privacy_smoke() {
|
|
local sensitive_pattern='(["'\''"]?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\''"]?[[:space:]]*[:=][[:space:]]*["'\''"]?[A-Za-z0-9._:/+=-]{8,}["'\''"]?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|-----BEGIN|BEGIN [A-Z ]*KEY'
|
|
local matches
|
|
matches="$(grep -RInE "$sensitive_pattern" "$REPORT_DIR" || true)"
|
|
if [[ -n "$matches" ]]; then
|
|
printf '%s\n' "$matches" >"$REPORT_DIR/privacy-findings.txt"
|
|
log "[oalaunch1][FAIL] report-privacy-smoke matched sensitive pattern"
|
|
STATUS=1
|
|
else
|
|
log "[oalaunch1][PASS] report-privacy-smoke"
|
|
fi
|
|
}
|
|
|
|
finish() {
|
|
write_manual_gates
|
|
run_privacy_smoke
|
|
write_summary_json || STATUS=1
|
|
local before_privacy_status="$STATUS"
|
|
run_privacy_smoke
|
|
if [[ "$STATUS" != "$before_privacy_status" ]]; then
|
|
write_summary_json || true
|
|
fi
|
|
local report_ref="${REPORT_DIR#$ROOT_DIR/}"
|
|
log "[oalaunch1] report=$report_ref"
|
|
exit "$STATUS"
|
|
}
|
|
|
|
trap finish EXIT
|
|
|
|
log "[oalaunch1] mode=oa-final-launch-readiness"
|
|
log "[oalaunch1] default=no live writes, no business writes, no DB restore"
|
|
run_optional_live_checks
|