完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)
这个提交包含在:
@@ -10,6 +10,8 @@ import { AppRouter } from "./router";
|
|||||||
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
|
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
document.documentElement.dataset.buildSha = String(import.meta.env.VITE_BUILD_SHA || "development").slice(0, 12);
|
||||||
|
|
||||||
const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted";
|
const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted";
|
||||||
const CHUNK_LOAD_ERROR_PATTERNS = [
|
const CHUNK_LOAD_ERROR_PATTERNS = [
|
||||||
"chunkloaderror",
|
"chunkloaderror",
|
||||||
|
|||||||
+18
-2
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
@@ -50,7 +50,22 @@ function resolveManualChunk(id: string) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
function normalizePublicBuildSha(value: string | undefined, fallback: string) {
|
||||||
|
const normalized = String(value ?? "").trim().toLowerCase();
|
||||||
|
return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized.slice(0, 12) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, ".", "VITE_");
|
||||||
|
const publicBuildSha = normalizePublicBuildSha(
|
||||||
|
env.VITE_BUILD_SHA,
|
||||||
|
mode === "production" ? "unknown" : "development",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
define: {
|
||||||
|
"import.meta.env.VITE_BUILD_SHA": JSON.stringify(publicBuildSha),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
@@ -157,4 +172,5 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-14
@@ -31,7 +31,7 @@ SIGNED_URL_TTL_SECONDS=600
|
|||||||
MAX_UPLOAD_SIZE_BYTES=10485760
|
MAX_UPLOAD_SIZE_BYTES=10485760
|
||||||
|
|
||||||
# ===== 服务端密钥(必须修改) =====
|
# ===== 服务端密钥(必须修改) =====
|
||||||
JWT_SECRET=change_me_in_trialrun
|
JWT_SECRET=<managed-jwt-secret>
|
||||||
FIELD_ENCRYPTION_KEY=
|
FIELD_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# ===== 密码 / 登录安全 =====
|
# ===== 密码 / 登录安全 =====
|
||||||
@@ -85,11 +85,11 @@ WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE=
|
|||||||
IM_NOTIFIER_ENABLED=true
|
IM_NOTIFIER_ENABLED=true
|
||||||
|
|
||||||
# ===== 数据库(Compose 内部连接) =====
|
# ===== 数据库(Compose 内部连接) =====
|
||||||
POSTGRES_DB=hyb_oa
|
POSTGRES_DB=<managed-db-name>
|
||||||
POSTGRES_USER=hyboa_user
|
POSTGRES_USER=<managed-db-user>
|
||||||
POSTGRES_PASSWORD=hyboa_password
|
POSTGRES_PASSWORD=<managed-db-password>
|
||||||
DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public
|
DATABASE_URL=<managed-postgresql-url>
|
||||||
# SHADOW_DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa_shadow
|
# SHADOW_DATABASE_URL=<managed-shadow-postgresql-url>
|
||||||
|
|
||||||
# ===== 存储与上传 =====
|
# ===== 存储与上传 =====
|
||||||
STORAGE_DIR=/data/storage
|
STORAGE_DIR=/data/storage
|
||||||
@@ -97,11 +97,11 @@ STORAGE_DRIVER=local
|
|||||||
S3_ENDPOINT=http://minio:9000
|
S3_ENDPOINT=http://minio:9000
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_BUCKET=hyb-oa
|
S3_BUCKET=hyb-oa
|
||||||
S3_ACCESS_KEY_ID=minioadmin
|
S3_ACCESS_KEY_ID=<managed-s3-access-key>
|
||||||
S3_SECRET_ACCESS_KEY=minioadmin
|
S3_SECRET_ACCESS_KEY=<managed-s3-secret>
|
||||||
S3_FORCE_PATH_STYLE=true
|
S3_FORCE_PATH_STYLE=true
|
||||||
MINIO_ROOT_USER=minioadmin
|
MINIO_ROOT_USER=<managed-minio-user>
|
||||||
MINIO_ROOT_PASSWORD=minioadmin
|
MINIO_ROOT_PASSWORD=<managed-minio-password>
|
||||||
|
|
||||||
# ===== OCR(off | mock | tencent) =====
|
# ===== OCR(off | mock | tencent) =====
|
||||||
OCR_PROVIDER=mock
|
OCR_PROVIDER=mock
|
||||||
@@ -154,7 +154,7 @@ INTEGRATION_OUTBOX_RETRY_SECONDS=120
|
|||||||
|
|
||||||
# ===== 邮件 =====
|
# ===== 邮件 =====
|
||||||
EMAIL_ENABLED=false
|
EMAIL_ENABLED=false
|
||||||
EMAIL_FROM=[email protected]
|
EMAIL_FROM=<sender-email>
|
||||||
EMAIL_BASE_URL=https://oa.example.com
|
EMAIL_BASE_URL=https://oa.example.com
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
@@ -177,10 +177,10 @@ AI_TIMEOUT_MS=10000
|
|||||||
AI_AUTH_TOKEN=
|
AI_AUTH_TOKEN=
|
||||||
|
|
||||||
# ===== Trialrun 初始化管理员 =====
|
# ===== Trialrun 初始化管理员 =====
|
||||||
TRIALRUN_ADMIN_EMAIL=admin@hyinda.com
|
TRIALRUN_ADMIN_EMAIL=<admin-email>
|
||||||
TRIALRUN_ADMIN_PHONE=13800000000
|
TRIALRUN_ADMIN_PHONE=<admin-phone>
|
||||||
TRIALRUN_ADMIN_PASSWORD=
|
TRIALRUN_ADMIN_PASSWORD=
|
||||||
TRIALRUN_ADMIN_EMPLOYEE_NO=T0000
|
TRIALRUN_ADMIN_EMPLOYEE_NO=<admin-employee-no>
|
||||||
TRIALRUN_ADMIN_NAME=Trialrun Admin
|
TRIALRUN_ADMIN_NAME=Trialrun Admin
|
||||||
TRIALRUN_ADMIN_TITLE=系统管理员
|
TRIALRUN_ADMIN_TITLE=系统管理员
|
||||||
TRIALRUN_ADMIN_HIRE_DATE=2020-01-01
|
TRIALRUN_ADMIN_HIRE_DATE=2020-01-01
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export class SourceProvenanceError extends Error {
|
||||||
|
constructor(code) {
|
||||||
|
super(code);
|
||||||
|
this.name = "SourceProvenanceError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_UNPUBLISHED_REASONS = new Set([
|
||||||
|
"offline-disaster-recovery",
|
||||||
|
"remote-outage-approved",
|
||||||
|
"controlled-local-recovery",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function runGit(rootDir, args) {
|
||||||
|
return spawnSync("git", args, {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitText(rootDir, args, failureCode) {
|
||||||
|
const result = runGit(rootDir, args);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new SourceProvenanceError(failureCode);
|
||||||
|
}
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSourceProvenance({ rootDir, allowUnpublished = false }) {
|
||||||
|
const inside = gitText(rootDir, ["rev-parse", "--is-inside-work-tree"], "not_a_git_worktree");
|
||||||
|
if (inside !== "true") {
|
||||||
|
throw new SourceProvenanceError("not_a_git_worktree");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = gitText(
|
||||||
|
rootDir,
|
||||||
|
["status", "--porcelain=v1", "--untracked-files=all"],
|
||||||
|
"git_status_failed",
|
||||||
|
);
|
||||||
|
if (dirty) {
|
||||||
|
throw new SourceProvenanceError("dirty_worktree");
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = gitText(rootDir, ["rev-parse", "--verify", "HEAD"], "commit_unavailable");
|
||||||
|
const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable") || "detached";
|
||||||
|
const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||||
|
const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null;
|
||||||
|
if (!upstream && !allowUnpublished) {
|
||||||
|
throw new SourceProvenanceError("upstream_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const published = Boolean(upstream) && runGit(rootDir, ["merge-base", "--is-ancestor", commit, upstream]).status === 0;
|
||||||
|
if (!published && !allowUnpublished) {
|
||||||
|
throw new SourceProvenanceError("commit_not_in_upstream");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "PASS",
|
||||||
|
commit,
|
||||||
|
shortCommit: commit.slice(0, 12),
|
||||||
|
branch,
|
||||||
|
upstream,
|
||||||
|
published,
|
||||||
|
overrideUsed: !published,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshUpstreamRef({ rootDir, allowFailure = false }) {
|
||||||
|
const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable");
|
||||||
|
const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||||
|
const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null;
|
||||||
|
if (!upstream) {
|
||||||
|
if (allowFailure) {
|
||||||
|
return { refreshed: false, upstream: null, remote: null, code: "upstream_missing" };
|
||||||
|
}
|
||||||
|
throw new SourceProvenanceError("upstream_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteResult = runGit(rootDir, ["config", "--get", `branch.${branch}.remote`]);
|
||||||
|
const remote = remoteResult.status === 0 ? remoteResult.stdout.trim() : null;
|
||||||
|
if (!remote || remote === ".") {
|
||||||
|
if (allowFailure) {
|
||||||
|
return { refreshed: false, upstream, remote: null, code: "upstream_remote_missing" };
|
||||||
|
}
|
||||||
|
throw new SourceProvenanceError("upstream_remote_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchResult = runGit(rootDir, ["fetch", "--quiet", "--prune", remote]);
|
||||||
|
if (fetchResult.status !== 0) {
|
||||||
|
if (allowFailure) {
|
||||||
|
return { refreshed: false, upstream, remote, code: "upstream_refresh_failed" };
|
||||||
|
}
|
||||||
|
throw new SourceProvenanceError("upstream_refresh_failed");
|
||||||
|
}
|
||||||
|
return { refreshed: true, upstream, remote, code: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRootArg(argv) {
|
||||||
|
const item = argv.find((value) => value.startsWith("--root="));
|
||||||
|
if (item) {
|
||||||
|
return path.resolve(item.slice("--root=".length));
|
||||||
|
}
|
||||||
|
return path.resolve(new URL("../..", import.meta.url).pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
try {
|
||||||
|
const rootDir = parseRootArg(process.argv.slice(2));
|
||||||
|
const allowUnpublished = process.env.DEPLOY_ALLOW_UNPUBLISHED_COMMIT === "1";
|
||||||
|
const overrideReason = String(process.env.DEPLOY_UNPUBLISHED_REASON ?? "").trim();
|
||||||
|
if (allowUnpublished && !ALLOWED_UNPUBLISHED_REASONS.has(overrideReason)) {
|
||||||
|
throw new SourceProvenanceError("unpublished_override_reason_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
collectSourceProvenance({ rootDir, allowUnpublished: true });
|
||||||
|
const refresh = refreshUpstreamRef({ rootDir, allowFailure: allowUnpublished });
|
||||||
|
const result = collectSourceProvenance({ rootDir, allowUnpublished });
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
...result,
|
||||||
|
remoteVerified: refresh.refreshed,
|
||||||
|
upstreamRefreshCode: refresh.code,
|
||||||
|
overrideRequested: allowUnpublished,
|
||||||
|
overrideReason: allowUnpublished ? overrideReason : null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof SourceProvenanceError ? error.code : "unexpected_error";
|
||||||
|
console.error(JSON.stringify({ status: "FAIL", code }));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
if (isMain) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
- [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile`
|
- [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile`
|
||||||
- [ ] 已确认 `oa.hyinda.com { reverse_proxy 127.0.0.1:3000 }` 生效
|
- [ ] 已确认 `oa.hyinda.com { reverse_proxy 127.0.0.1:3000 }` 生效
|
||||||
- [ ] 已执行 `systemctl reload caddy`
|
- [ ] 已执行 `systemctl reload caddy`
|
||||||
- [ ] 已从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh`
|
- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,并从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh`
|
||||||
- [ ] 已通过 `https://<oa-domain>/ready` 或首页登录完成最小探活
|
- [ ] 已通过 `https://<oa-domain>/ready` 或首页登录完成最小探活
|
||||||
- [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份
|
- [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份
|
||||||
- [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据
|
- [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据
|
||||||
- [ ] 不导入历史审批单、通知历史、审计日志、聊天记录和涉密正文附件
|
- [ ] 不导入历史审批单、通知历史、审计日志、聊天记录和涉密正文附件
|
||||||
- [ ] 已确认远端部署目录 `/opt/hyb-oa-trialrun` 不是 git 工作树,发布优先走 `bash deploy/trialrun/deploy.sh`
|
- [ ] 已确认远端部署目录不是 git 工作树,发布优先走 Git archive 模式的 `bash deploy/trialrun/deploy.sh`
|
||||||
- [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:3000`
|
- [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:3000`
|
||||||
|
|
||||||
## A. 发布前(Pre-flight)
|
## A. 发布前(Pre-flight)
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
- [ ] `./scripts/backup-storage.sh`(记录输出文件名)
|
- [ ] `./scripts/backup-storage.sh`(记录输出文件名)
|
||||||
|
|
||||||
## B. 发布(Deploy)
|
## B. 发布(Deploy)
|
||||||
- [ ] 在本地仓库切到目标版本:`git checkout <tag>`,然后执行 `bash deploy/trialrun/deploy.sh`
|
- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致
|
||||||
- [ ] 构建并启动:
|
- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识
|
||||||
- [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server`
|
- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
|
||||||
- [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init`
|
- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
|
||||||
- [ ] 探活:
|
- [ ] 探活:
|
||||||
- [ ] `curl -fsS http://127.0.0.1:3000/ready`
|
- [ ] `curl -fsS http://127.0.0.1:3000/ready`
|
||||||
- [ ] Runtime smoke:
|
- [ ] Runtime smoke:
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
- [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过
|
- [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过
|
||||||
- [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION`
|
- [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION`
|
||||||
- [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新
|
- [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新
|
||||||
|
- [ ] 公网 bundle 扫描确认包含匹配的 12 位构建标识,且不包含完整提交 SHA
|
||||||
- [ ] 登录验证已由 live smoke 与 release gate 覆盖;仅首次接入或第三方后台配置变更时补做人工确认
|
- [ ] 登录验证已由 live smoke 与 release gate 覆盖;仅首次接入或第三方后台配置变更时补做人工确认
|
||||||
|
|
||||||
## C. 发布后(Post-check)
|
## C. 发布后(Post-check)
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
|
|
||||||
|
|
||||||
## E. R12-lite 试点切换检查
|
## E. R12-lite 试点切换检查
|
||||||
- [ ] 已执行 `cd server && DATABASE_URL=postgresql://hyboa_user:[email protected]:5432/hyb_oa_r12?schema=public npm run seed:config`
|
- [ ] 已执行 `cd server && DATABASE_URL=<local-postgresql-url> npm run seed:config`
|
||||||
- [ ] 运行环境已打开 R12-lite 所需 env 开关
|
- [ ] 运行环境已打开 R12-lite 所需 env 开关
|
||||||
- [ ] 功能开关页已将 `budget_precheck_v1`、`payment_batch_v1`、`portal_home_v1`、`global_search_v1`、`announcements_v1`、`workflow_definition_admin_v1`、`cutover_console_v1` 切到 `inherit` 或 `force_on`
|
- [ ] 功能开关页已将 `budget_precheck_v1`、`payment_batch_v1`、`portal_home_v1`、`global_search_v1`、`announcements_v1`、`workflow_definition_admin_v1`、`cutover_console_v1` 切到 `inherit` 或 `force_on`
|
||||||
- [ ] 旧 OA 主数据 CSV 已完成 dry-run,结果为 `dry_run_ok`
|
- [ ] 旧 OA 主数据 CSV 已完成 dry-run,结果为 `dry_run_ok`
|
||||||
|
|||||||
+75
-18
@@ -5,7 +5,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
DEPLOY_SERVER="${DEPLOY_SERVER:-deploy@118.89.77.218}"
|
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
|
||||||
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
|
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
|
||||||
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
|
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
|
||||||
ENABLE_WORKER="${ENABLE_WORKER:-0}"
|
ENABLE_WORKER="${ENABLE_WORKER:-0}"
|
||||||
@@ -16,6 +16,16 @@ LOCAL_ENV_FILE="deploy/trialrun/.env"
|
|||||||
ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
|
ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
|
||||||
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js"
|
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js"
|
||||||
EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js"
|
EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js"
|
||||||
|
PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs"
|
||||||
|
STAGING_DIR=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then
|
||||||
|
rm -rf "$STAGING_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
need_cmd() {
|
need_cmd() {
|
||||||
command -v "$1" >/dev/null 2>&1 || {
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
@@ -27,6 +37,34 @@ need_cmd() {
|
|||||||
need_cmd rsync
|
need_cmd rsync
|
||||||
need_cmd ssh
|
need_cmd ssh
|
||||||
need_cmd node
|
need_cmd node
|
||||||
|
need_cmd git
|
||||||
|
need_cmd tar
|
||||||
|
need_cmd curl
|
||||||
|
need_cmd mktemp
|
||||||
|
|
||||||
|
if [[ -z "$DEPLOY_SERVER" ]]; then
|
||||||
|
echo "[ERROR] 必须显式提供 DEPLOY_SERVER" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then
|
||||||
|
echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> 0. 校验 Git 来源与发布提交"
|
||||||
|
PROVENANCE_JSON="$(node "${ROOT_DIR}/${PROVENANCE_SCRIPT}")"
|
||||||
|
SOURCE_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.commit)' "$PROVENANCE_JSON")"
|
||||||
|
SOURCE_SHORT_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.shortCommit)' "$PROVENANCE_JSON")"
|
||||||
|
SOURCE_BRANCH="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.branch)' "$PROVENANCE_JSON")"
|
||||||
|
SOURCE_PUBLISHED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.published))' "$PROVENANCE_JSON")"
|
||||||
|
SOURCE_REMOTE_VERIFIED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.remoteVerified))' "$PROVENANCE_JSON")"
|
||||||
|
SOURCE_OVERRIDE_USED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideUsed))' "$PROVENANCE_JSON")"
|
||||||
|
SOURCE_OVERRIDE_REASON="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideReason || ""))' "$PROVENANCE_JSON")"
|
||||||
|
echo "[deploy-source] commit=${SOURCE_COMMIT} branch=${SOURCE_BRANCH} published=${SOURCE_PUBLISHED} remoteVerified=${SOURCE_REMOTE_VERIFIED}"
|
||||||
|
if [[ "$SOURCE_OVERRIDE_USED" == "true" || "$SOURCE_REMOTE_VERIFIED" != "true" ]]; then
|
||||||
|
echo "[deploy-source][UNPUBLISHED-OVERRIDE] reason=${SOURCE_OVERRIDE_REASON} remoteVerified=${SOURCE_REMOTE_VERIFIED}" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "${ROOT_DIR}/${COMPOSE_FILE}" ]]; then
|
if [[ ! -f "${ROOT_DIR}/${COMPOSE_FILE}" ]]; then
|
||||||
echo "[ERROR] 缺少 ${COMPOSE_FILE}" >&2
|
echo "[ERROR] 缺少 ${COMPOSE_FILE}" >&2
|
||||||
@@ -53,13 +91,17 @@ if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">>> 0. 校验部署环境契约"
|
echo ">>> 1. 校验部署环境契约"
|
||||||
node "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}"
|
node "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}"
|
||||||
|
|
||||||
echo ">>> 1. 创建远端目录"
|
echo ">>> 2. 从已验证提交生成不可变同步目录"
|
||||||
|
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hyb-oa-deploy.XXXXXX")"
|
||||||
|
git -C "$ROOT_DIR" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$STAGING_DIR"
|
||||||
|
|
||||||
|
echo ">>> 3. 创建远端目录"
|
||||||
ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
|
ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
|
||||||
|
|
||||||
echo ">>> 2. 同步 OA 仓库到服务器"
|
echo ">>> 4. 同步已验证提交到服务器"
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
--exclude '.git' \
|
--exclude '.git' \
|
||||||
--exclude '.github' \
|
--exclude '.github' \
|
||||||
@@ -67,6 +109,7 @@ rsync -az --delete \
|
|||||||
--exclude '.agents' \
|
--exclude '.agents' \
|
||||||
--exclude '.codex' \
|
--exclude '.codex' \
|
||||||
--exclude '.claude' \
|
--exclude '.claude' \
|
||||||
|
--exclude '.trellis' \
|
||||||
--exclude 'node_modules' \
|
--exclude 'node_modules' \
|
||||||
--exclude 'client/node_modules' \
|
--exclude 'client/node_modules' \
|
||||||
--exclude 'server/node_modules' \
|
--exclude 'server/node_modules' \
|
||||||
@@ -81,20 +124,20 @@ rsync -az --delete \
|
|||||||
--exclude 'coverage' \
|
--exclude 'coverage' \
|
||||||
--exclude 'dist' \
|
--exclude 'dist' \
|
||||||
--exclude '*.log' \
|
--exclude '*.log' \
|
||||||
"${ROOT_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/"
|
"${STAGING_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/"
|
||||||
|
|
||||||
if [[ "${SYNC_ENV}" == "1" ]]; then
|
if [[ "${SYNC_ENV}" == "1" ]]; then
|
||||||
echo ">>> 3. 同步部署环境变量"
|
echo ">>> 5. 同步部署环境变量"
|
||||||
ssh "${DEPLOY_SERVER}" "mkdir -p '$(dirname "${REMOTE_ENV_FILE}")'"
|
ssh "${DEPLOY_SERVER}" "mkdir -p '$(dirname "${REMOTE_ENV_FILE}")'"
|
||||||
rsync -az "${ROOT_DIR}/${LOCAL_ENV_FILE}" "${DEPLOY_SERVER}:${REMOTE_ENV_FILE}"
|
rsync -az "${ROOT_DIR}/${LOCAL_ENV_FILE}" "${DEPLOY_SERVER}:${REMOTE_ENV_FILE}"
|
||||||
else
|
else
|
||||||
echo ">>> 3. 跳过 .env 同步(默认安全模式)"
|
echo ">>> 5. 跳过 .env 同步(默认安全模式)"
|
||||||
echo " 如果你确认本地 deploy/trialrun/.env 可直接上服务器,可用:SYNC_ENV=1 bash deploy/trialrun/deploy.sh"
|
echo " 如果你确认本地 deploy/trialrun/.env 可直接上服务器,可用:SYNC_ENV=1 bash deploy/trialrun/deploy.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">>> 4. 在服务器执行部署与 smoke"
|
echo ">>> 6. 在服务器执行部署与 smoke"
|
||||||
ssh "${DEPLOY_SERVER}" \
|
ssh "${DEPLOY_SERVER}" \
|
||||||
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' bash -s" <<'REMOTE'
|
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$REMOTE_DIR"
|
cd "$REMOTE_DIR"
|
||||||
mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio
|
mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio
|
||||||
@@ -147,13 +190,18 @@ if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma mig
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">>> 4.6 等待 /ready"
|
echo ">>> 4.6 等待 /ready 并核对构建标识"
|
||||||
ready_ok=0
|
ready_ok=0
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -fsS http://127.0.0.1:3000/ready >/dev/null; then
|
if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then
|
||||||
|
if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e '
|
||||||
|
const payload = JSON.parse(process.env.READY_PAYLOAD || "{}");
|
||||||
|
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
|
||||||
|
'; then
|
||||||
ready_ok=1
|
ready_ok=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -190,18 +238,27 @@ for required_key in LIVE_SMOKE_ADMIN_IDENTIFIER LIVE_SMOKE_ADMIN_PASSWORD
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$LIVE_SMOKE_READY" == "1" ]]; then
|
if [[ "$LIVE_SMOKE_READY" == "1" ]]; then
|
||||||
echo ">>> 5. 执行部署后 live smoke"
|
echo ">>> 7. 执行部署后 live smoke"
|
||||||
bash "${ROOT_DIR}/scripts/qa/run-web-gates.sh" smoke-live
|
bash "${ROOT_DIR}/scripts/qa/run-web-gates.sh" smoke-live
|
||||||
else
|
else
|
||||||
echo ">>> 5. 跳过部署后 live smoke(未提供完整 LIVE_SMOKE_* 凭据)"
|
echo ">>> 7. 跳过部署后 live smoke(未提供完整凭据)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">>> 6. 执行公网前端资源文案扫描"
|
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||||
node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
echo ">>> 8. 核对公网构建标识"
|
||||||
|
PUBLIC_READY_PAYLOAD="$(curl -fsS "${PUBLIC_BASE_URL}/ready")"
|
||||||
|
READY_PAYLOAD="$PUBLIC_READY_PAYLOAD" EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" node -e '
|
||||||
|
const payload = JSON.parse(process.env.READY_PAYLOAD || "{}");
|
||||||
|
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
|
||||||
|
'
|
||||||
|
|
||||||
echo ">>> 7. 部署完成"
|
echo ">>> 9. 执行公网前端资源文案扫描"
|
||||||
echo " 远端目录: ${REMOTE_DIR}"
|
PUBLIC_EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" \
|
||||||
echo " 环境文件: ${REMOTE_ENV_FILE}"
|
PUBLIC_FORBIDDEN_BUILD_SHA="$SOURCE_COMMIT" \
|
||||||
|
node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL"
|
||||||
|
|
||||||
|
echo ">>> 10. 部署完成"
|
||||||
|
echo " 来源提交: ${SOURCE_COMMIT}"
|
||||||
echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描"
|
echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描"
|
||||||
echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
|
echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
|
||||||
echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000"
|
echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000"
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: deploy/trialrun/Dockerfile
|
dockerfile: deploy/trialrun/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||||
|
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
|
||||||
container_name: hyb-oa-trialrun-server
|
container_name: hyb-oa-trialrun-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -64,6 +67,7 @@ services:
|
|||||||
- ./.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -73,6 +77,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: deploy/trialrun/Dockerfile
|
dockerfile: deploy/trialrun/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||||
|
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
|
||||||
container_name: hyb-oa-trialrun-worker
|
container_name: hyb-oa-trialrun-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["worker"]
|
profiles: ["worker"]
|
||||||
@@ -84,6 +91,7 @@ services:
|
|||||||
command: ["npm", "run", "worker"]
|
command: ["npm", "run", "worker"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||||
SERVE_CLIENT_DIST: "false"
|
SERVE_CLIENT_DIST: "false"
|
||||||
JOBS_ENABLED: "true"
|
JOBS_ENABLED: "true"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -93,6 +101,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: deploy/trialrun/Dockerfile
|
dockerfile: deploy/trialrun/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||||
|
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
|
||||||
container_name: hyb-oa-trialrun-init
|
container_name: hyb-oa-trialrun-init
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -101,6 +112,7 @@ services:
|
|||||||
- ./.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/storage:${STORAGE_DIR}
|
- ./data/storage:${STORAGE_DIR}
|
||||||
entrypoint: ["sh", "-c"]
|
entrypoint: ["sh", "-c"]
|
||||||
|
|||||||
+15
-11
@@ -5,21 +5,18 @@
|
|||||||
## 0A. 如果官网 `hyb-web` 已在同机运行(推荐方案)
|
## 0A. 如果官网 `hyb-web` 已在同机运行(推荐方案)
|
||||||
- 官网继续保持 `hyinda.com / www.hyinda.com` 静态托管,不要改官网目录和配置。
|
- 官网继续保持 `hyinda.com / www.hyinda.com` 静态托管,不要改官网目录和配置。
|
||||||
- OA 新增独立二级域名,例如 `oa.hyinda.com`。
|
- OA 新增独立二级域名,例如 `oa.hyinda.com`。
|
||||||
- OA 推荐部署目录:`/opt/hyb-oa-trialrun`。
|
- OA 部署目录由受控环境中的 `REMOTE_DIR` 指定,不在仓库固化机器路径。
|
||||||
- OA 推荐访问方式:`Caddy(443) -> 127.0.0.1:3000 -> docker-compose(server)`。
|
- OA 推荐访问方式:`Caddy(443) -> 127.0.0.1:3000 -> docker-compose(server)`。
|
||||||
- 首次初始化时:
|
- 首次初始化时:
|
||||||
1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh`
|
1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh`
|
||||||
2. 手工创建 `deploy/trialrun/.env`
|
2. 手工创建 `deploy/trialrun/.env`
|
||||||
3. 手工更新 OA 的 `Caddyfile` 站点块并确认 TLS 正常
|
3. 手工更新 OA 的 `Caddyfile` 站点块并确认 TLS 正常
|
||||||
4. 本地执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`)
|
4. 本地通过受控配置显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,再执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`)
|
||||||
|
|
||||||
## 0B. 当前已核实的 `118.89.77.218` 部署基线(2026-05-04)
|
## 0B. 历史部署基线(基础设施信息已脱敏)
|
||||||
- SSH 用户:
|
- SSH 目标、部署账号和机器地址只保存在受控运维配置中,通过 `DEPLOY_SERVER` 显式传入。
|
||||||
- 常规部署:`[email protected]`
|
|
||||||
- 系统配置:`[email protected]`
|
|
||||||
- `shunli` 当前不可免密登录
|
|
||||||
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 已反代到 `127.0.0.1:3000`
|
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 已反代到 `127.0.0.1:3000`
|
||||||
- 部署目录:`/opt/hyb-oa-trialrun`
|
- 部署目录通过 `REMOTE_DIR` 显式传入。
|
||||||
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
|
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
|
||||||
- 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准
|
- 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准
|
||||||
- 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true`
|
- 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true`
|
||||||
@@ -43,6 +40,7 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
|
|||||||
|
|
||||||
环境变量约束:
|
环境变量约束:
|
||||||
- `deploy/trialrun/.env` 现在是 trialrun 部署事实源,`server / worker / init` 统一通过 `env_file` 注入。
|
- `deploy/trialrun/.env` 现在是 trialrun 部署事实源,`server / worker / init` 统一通过 `env_file` 注入。
|
||||||
|
- `BUILD_SHA` / `VITE_BUILD_SHA` 由发布入口按已批准提交临时注入,不写入长期 `.env`;前者供后端运行时核对,后者只允许 12 位短 SHA 进入公网 bundle。
|
||||||
- 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。
|
- 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。
|
||||||
- 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。
|
- 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。
|
||||||
- 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。
|
- 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。
|
||||||
@@ -60,10 +58,14 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
|
|||||||
```bash
|
```bash
|
||||||
cd /path/to/hyb-oa
|
cd /path/to/hyb-oa
|
||||||
node deploy/trialrun/validate-env-contract.js
|
node deploy/trialrun/validate-env-contract.js
|
||||||
|
export BUILD_SHA="<approved-full-commit>"
|
||||||
|
export VITE_BUILD_SHA="<matching-12-char-commit>"
|
||||||
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
|
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
|
||||||
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
|
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
以上直接 Compose 命令仅用于首次初始化或受控应急;正式发布优先使用 `deploy/trialrun/deploy.sh`,由脚本从 clean、已进入 upstream 的提交自动注入两个构建标识。缺少任一标识时 Compose 必须拒绝解析。
|
||||||
|
|
||||||
可选:启用 MinIO(对象存储,F21):
|
可选:启用 MinIO(对象存储,F21):
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/hyb-oa
|
cd /path/to/hyb-oa
|
||||||
@@ -100,7 +102,9 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
|
|||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- `deploy/trialrun/deploy.sh` 现已自动执行 `validate-env-contract.js`、`/ready` 探活、`smoke-external-deps.js` 与 `smoke-trialrun-runtime.js`。
|
- `deploy/trialrun/deploy.sh` 会先拒绝脏工作树、确认提交已进入 upstream,再从该提交生成 Git archive;随后自动执行环境契约、带 build SHA 的 `/ready` 核对、external deps smoke 与 runtime smoke。
|
||||||
|
- 标准发布会先刷新当前 branch 的 upstream remote-tracking ref,再判断目标提交是否已发布。
|
||||||
|
- 只有明确的离线灾备或已批准远端故障场景才可设置 `DEPLOY_ALLOW_UNPUBLISHED_COMMIT=1`,并同时提供 allowlist 原因码 `DEPLOY_UNPUBLISHED_REASON=offline-disaster-recovery|remote-outage-approved|controlled-local-recovery`;部署日志会醒目标记 override,该开关不放宽 clean-tree 要求。
|
||||||
- 若本地存在完整 `LIVE_SMOKE_*` 环境变量,部署脚本会继续执行 `client npm run test:e2e:live`,用于公网只读 smoke。
|
- 若本地存在完整 `LIVE_SMOKE_*` 环境变量,部署脚本会继续执行 `client npm run test:e2e:live`,用于公网只读 smoke。
|
||||||
- 推荐直接执行 `bash scripts/qa/run-release-gate.sh all`,让 static / local smoke / full-local / deploy / live smoke 形成单一放行链。
|
- 推荐直接执行 `bash scripts/qa/run-release-gate.sh all`,让 static / local smoke / full-local / deploy / live smoke 形成单一放行链。
|
||||||
- 若再出现“数据库开了但功能被环境硬关”的情况,部署会直接失败,不再等到手工点页面才发现。
|
- 若再出现“数据库开了但功能被环境硬关”的情况,部署会直接失败,不再等到手工点页面才发现。
|
||||||
@@ -231,7 +235,7 @@ curl -fsS http://127.0.0.1:3000/ready
|
|||||||
|
|
||||||
步骤:
|
步骤:
|
||||||
1) 先做备份(见 E14 脚本)
|
1) 先做备份(见 E14 脚本)
|
||||||
2) 从本地仓库执行 `bash deploy/trialrun/deploy.sh`(推荐);该脚本会把当前工作树 `rsync` 到 `/opt/hyb-oa-trialrun`,不依赖远端 `git pull`
|
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
|
||||||
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
|
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
|
||||||
3) 构建并重启:
|
3) 构建并重启:
|
||||||
```bash
|
```bash
|
||||||
@@ -310,7 +314,7 @@ docker logs -f hyb-oa-trialrun-server
|
|||||||
## 6. R12-lite 试点切换 Runbook
|
## 6. R12-lite 试点切换 Runbook
|
||||||
|
|
||||||
### 6.1 打开本轮功能前置条件
|
### 6.1 打开本轮功能前置条件
|
||||||
- 执行 `cd server && DATABASE_URL=postgresql://hyboa_user:[email protected]:5432/hyb_oa_r12?schema=public npm run seed:config`,确保 R12-lite 开关与默认配置落库。
|
- 执行 `cd server && DATABASE_URL=<local-postgresql-url> npm run seed:config`,确保 R12-lite 开关与默认配置落库。
|
||||||
- 在运行环境启用对应 env:`BUDGET_PRECHECK_ENABLED=true`、`PAYMENT_BATCH_ENABLED=true`、`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`WORKFLOW_DEFINITION_ADMIN_ENABLED=true`、`CUTOVER_CONSOLE_ENABLED=true`。
|
- 在运行环境启用对应 env:`BUDGET_PRECHECK_ENABLED=true`、`PAYMENT_BATCH_ENABLED=true`、`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`WORKFLOW_DEFINITION_ADMIN_ENABLED=true`、`CUTOVER_CONSOLE_ENABLED=true`。
|
||||||
- 登录系统后在“功能开关”页将对应开关切到 `inherit` 或 `force_on`;仅设置 env 不会自动放开 DB 中的 `force_off` 记录。
|
- 登录系统后在“功能开关”页将对应开关切到 `inherit` 或 `force_on`;仅设置 env 不会自动放开 DB 中的 `force_off` 记录。
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example');
|
|||||||
const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml');
|
const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml');
|
||||||
|
|
||||||
const requiredServices = ['server', 'worker', 'init'];
|
const requiredServices = ['server', 'worker', 'init'];
|
||||||
|
const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']);
|
||||||
const allowedOverrides = {
|
const allowedOverrides = {
|
||||||
server: ['NODE_ENV'],
|
server: ['NODE_ENV', 'BUILD_SHA'],
|
||||||
worker: ['NODE_ENV', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
|
worker: ['NODE_ENV', 'BUILD_SHA', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
|
||||||
init: ['NODE_ENV'],
|
init: ['NODE_ENV', 'BUILD_SHA'],
|
||||||
|
};
|
||||||
|
const requiredRuntimeOverrides = {
|
||||||
|
server: ['BUILD_SHA'],
|
||||||
|
worker: ['BUILD_SHA'],
|
||||||
|
init: ['BUILD_SHA'],
|
||||||
|
};
|
||||||
|
const requiredBuildArgs = {
|
||||||
|
server: ['BUILD_SHA', 'VITE_BUILD_SHA'],
|
||||||
|
worker: ['BUILD_SHA', 'VITE_BUILD_SHA'],
|
||||||
|
init: ['BUILD_SHA', 'VITE_BUILD_SHA'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function readFile(filePath) {
|
function readFile(filePath) {
|
||||||
@@ -117,10 +128,15 @@ const envExampleKeys = extractEnvExampleKeys(envExampleContent);
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
for (const key of configKeys) {
|
for (const key of configKeys) {
|
||||||
if (!envExampleKeys.has(key)) {
|
if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) {
|
||||||
errors.push(`.env.example 缺少 ${key}`);
|
errors.push(`.env.example 缺少 ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const key of deploymentInjectedEnvKeys) {
|
||||||
|
if (envExampleKeys.has(key)) {
|
||||||
|
errors.push(`.env.example 不应持久化部署注入键 ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const serviceName of requiredServices) {
|
for (const serviceName of requiredServices) {
|
||||||
const block = extractServiceBlock(composeContent, serviceName);
|
const block = extractServiceBlock(composeContent, serviceName);
|
||||||
@@ -138,10 +154,22 @@ for (const serviceName of requiredServices) {
|
|||||||
const illegalOverrides = environmentKeys.filter(
|
const illegalOverrides = environmentKeys.filter(
|
||||||
(key) => !(allowedOverrides[serviceName] ?? []).includes(key),
|
(key) => !(allowedOverrides[serviceName] ?? []).includes(key),
|
||||||
);
|
);
|
||||||
|
const missingRuntimeOverrides = (requiredRuntimeOverrides[serviceName] ?? []).filter(
|
||||||
|
(key) => !environmentKeys.includes(key),
|
||||||
|
);
|
||||||
|
const missingBuildArgs = (requiredBuildArgs[serviceName] ?? []).filter(
|
||||||
|
(key) => !new RegExp(`^\\s{8}${key}:`, 'm').test(block),
|
||||||
|
);
|
||||||
|
|
||||||
if (illegalOverrides.length) {
|
if (illegalOverrides.length) {
|
||||||
errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`);
|
errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
if (missingRuntimeOverrides.length) {
|
||||||
|
errors.push(`服务 ${serviceName} 缺少运行时 override: ${missingRuntimeOverrides.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (missingBuildArgs.length) {
|
||||||
|
errors.push(`服务 ${serviceName} 缺少构建参数: ${missingBuildArgs.join(', ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ R7E 前和后续非微小改动至少从以下组合中按风险选择;进入
|
|||||||
|
|
||||||
| 类别 | 命令或证据 | 口径 |
|
| 类别 | 命令或证据 | 口径 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 历史 CSV 兼容 | `python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py <issue.csv>` | 仅当当前任务包含 legacy/export CSV 时校验通过 |
|
| 历史 CSV 兼容 | `python3 <validate_issues_csv.py-path> <issue.csv>` | 仅当当前任务包含 legacy/export CSV 时校验通过 |
|
||||||
| 前端基础 | `cd client && npm run typecheck`、`cd client && npm run build` | 类型和构建通过,Vite chunk warning 不单独阻断 |
|
| 前端基础 | `cd client && npm run typecheck`、`cd client && npm run build` | 类型和构建通过,Vite chunk warning 不单独阻断 |
|
||||||
| UI 规则脚本 | `node scripts/qa/check-ui-design-rules.mjs --strict` | 当前 UI 静态规则 0 findings;视觉与交互裁决以 `frontend-design` 为准 |
|
| UI 规则脚本 | `node scripts/qa/check-ui-design-rules.mjs --strict` | 当前 UI 静态规则 0 findings;视觉与交互裁决以 `frontend-design` 为准 |
|
||||||
| MIF7 移动矩阵 | `cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts --workers=1 --reporter=list` | 首页、进度/审批、通知、三类详情和财务首页通过;公网时设置 `E2E_BASE_URL=https://oa.hyinda.com` |
|
| MIF7 移动矩阵 | `cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts --workers=1 --reporter=list` | 首页、进度/审批、通知、三类详情和财务首页通过;公网时设置 `E2E_BASE_URL=https://oa.hyinda.com` |
|
||||||
|
|||||||
+2
-2
@@ -240,9 +240,9 @@ PROJECT_CLOSURE_CHECKLIST_ENABLED=true
|
|||||||
|
|
||||||
## 公网与部署
|
## 公网与部署
|
||||||
|
|
||||||
当前公网服务器信息以实际配置为准。已知历史信息:
|
当前公网服务器信息以受控运维配置为准。历史基础设施信息已脱敏:
|
||||||
|
|
||||||
- 服务器:`118.89.77.218`
|
- 服务器:由 `DEPLOY_SERVER` 在执行时显式提供,不写入仓库。
|
||||||
- 反代:Caddy
|
- 反代:Caddy
|
||||||
- 域名示例:`oa.hyinda.com`
|
- 域名示例:`oa.hyinda.com`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ V1 管理口径以实物责任、流转、盘点和离职交接为准,不按
|
|||||||
|
|
||||||
资产材料上传必须先明确材料类型,例如采购来源证明、领用/归还证明、盘点证据、处置证明、维修记录、电子资料索引或备份验证证据。不同类型适用不同权限、敏感级别、审计和展示位置;合同正文、发票原件、付款回单等业务原文仍走对应业务模块,不进入资产万能附件入口。
|
资产材料上传必须先明确材料类型,例如采购来源证明、领用/归还证明、盘点证据、处置证明、维修记录、电子资料索引或备份验证证据。不同类型适用不同权限、敏感级别、审计和展示位置;合同正文、发票原件、付款回单等业务原文仍走对应业务模块,不进入资产万能附件入口。
|
||||||
|
|
||||||
历史目录第一轮只允许从 `/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引` 读取脱敏索引和元数据,默认 dry-run。公网写入真实索引必须另行确认并带写入开关。
|
历史目录第一轮只允许从 `<controlled-company-index-root>` 读取脱敏索引和元数据,默认 dry-run。公网写入真实索引必须另行确认并带写入开关。
|
||||||
|
|
||||||
部署服务器空间不足时,不把普通办公电脑共享目录作为生产附件后端。优先顺序为:继续本地存储短期承载授权业务附件、只保存索引并由负责人线下调取原件、再评估腾讯云 COS 或受控 NAS/MinIO。家用群晖 NAS 可作为冷归档、备份或低风险 MinIO 试点;只有具备 VPN、私有 bucket、OA 服务账户、UPS、容量告警和备份恢复演练后,才可扩大使用范围。
|
部署服务器空间不足时,不把普通办公电脑共享目录作为生产附件后端。优先顺序为:继续本地存储短期承载授权业务附件、只保存索引并由负责人线下调取原件、再评估腾讯云 COS 或受控 NAS/MinIO。家用群晖 NAS 可作为冷归档、备份或低风险 MinIO 试点;只有具备 VPN、私有 bucket、OA 服务账户、UPS、容量告警和备份恢复演练后,才可扩大使用范围。
|
||||||
|
|
||||||
|
|||||||
+22
-5
@@ -3,21 +3,31 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
echo "=== [1/5] Process Contract Gate ==="
|
echo "=== [1/7] Process Contract Gate ==="
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
node scripts/qa/check-process-contract.mjs --baseline
|
node scripts/qa/check-process-contract.mjs --baseline
|
||||||
)
|
)
|
||||||
|
|
||||||
echo ""
|
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"
|
cd "$ROOT_DIR/server"
|
||||||
npm run prisma:validate
|
npm run prisma:validate
|
||||||
)
|
)
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [3/5] Server Static Checks ==="
|
echo "=== [4/7] Server Static Checks ==="
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR/server"
|
cd "$ROOT_DIR/server"
|
||||||
node --check src/routes/reimbursements.js
|
node --check src/routes/reimbursements.js
|
||||||
@@ -69,7 +79,14 @@ echo "=== [3/5] Server Static Checks ==="
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo ""
|
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"
|
cd "$ROOT_DIR/client"
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
@@ -77,7 +94,7 @@ echo "=== [4/5] Client Typecheck + Build ==="
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [5/5] Feature Matrix Consistency ==="
|
echo "=== [7/7] Feature Matrix Consistency ==="
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
python3 scripts/validate-feature-matrix.py
|
python3 scripts/validate-feature-matrix.py
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
|
|
||||||
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
|
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) {
|
function present(key) {
|
||||||
return Boolean(process.env[key]?.trim());
|
return Boolean(process.env[key]?.trim());
|
||||||
@@ -108,7 +124,7 @@ function addMissing(checks) {
|
|||||||
checks.missing.push("ocrOrEmployeeCredentialReady");
|
checks.missing.push("ocrOrEmployeeCredentialReady");
|
||||||
}
|
}
|
||||||
if (!checks.liveOcr.invoiceSampleReady) {
|
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 ready = await fetchJson(`${baseUrl}/ready`);
|
||||||
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
||||||
const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`);
|
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 = {
|
const result = {
|
||||||
|
status,
|
||||||
|
findingCount: findings.length,
|
||||||
|
findings,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
...checks,
|
...checks,
|
||||||
publicReady: {
|
publicReady: {
|
||||||
ok: ready.ok,
|
ok: ready.ok,
|
||||||
status: ready.status,
|
status: ready.status,
|
||||||
|
expectedBuildSha,
|
||||||
|
observedBuildSha,
|
||||||
|
buildShaMatches: buildMatches,
|
||||||
},
|
},
|
||||||
wecomConfig: {
|
wecomConfig: {
|
||||||
ok: wecomConfig.ok,
|
ok: wecomConfig.ok,
|
||||||
@@ -135,7 +179,7 @@ async function main() {
|
|||||||
entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null,
|
entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null,
|
||||||
oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null,
|
oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null,
|
||||||
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
||||||
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
|
loginUrlReady: Boolean(wecomConfig.json?.data?.loginUrl),
|
||||||
},
|
},
|
||||||
authCapabilities: {
|
authCapabilities: {
|
||||||
ok: authCapabilities.ok,
|
ok: authCapabilities.ok,
|
||||||
@@ -150,8 +194,10 @@ async function main() {
|
|||||||
if (jsonOnly) {
|
if (jsonOnly) {
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
} else {
|
} 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] base=${result.baseUrl}`);
|
||||||
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
|
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] 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] authCapabilities=${result.authCapabilities.ok} emailDelivery=${result.authCapabilities.emailDeliveryReady}`);
|
||||||
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-61
@@ -1,78 +1,129 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { PrismaClient } from "../../server/node_modules/@prisma/client/index.js";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
function parseThresholds() {
|
export function parseThresholds(argv = process.argv.slice(2)) {
|
||||||
const arg = process.argv.find((item) => item.startsWith("--thresholds="));
|
const arg = argv.find((item) => item.startsWith("--thresholds="));
|
||||||
const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10";
|
const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10";
|
||||||
return raw
|
return Array.from(new Set(
|
||||||
|
raw
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((item) => Number(item.trim()))
|
.map((item) => Number(item.trim()))
|
||||||
.filter((item) => Number.isFinite(item) && item > 0)
|
.filter((item) => Number.isFinite(item) && item > 0)
|
||||||
.sort((a, b) => a - b);
|
.map((item) => Math.floor(item)),
|
||||||
|
)).sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
function minutesAgo(minutes) {
|
function minutesAgo(minutes, now) {
|
||||||
return new Date(Date.now() - minutes * 60_000);
|
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() {
|
async function main() {
|
||||||
if (!process.env.DATABASE_URL) {
|
const jsonOnly = process.argv.includes("--json");
|
||||||
console.log("[pending-ocr][SKIP] missing DATABASE_URL");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const thresholds = parseThresholds();
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const summary = [];
|
const summary = await executePendingOcrCheck();
|
||||||
for (const minutes of thresholds) {
|
printSummary(summary, jsonOnly);
|
||||||
const where = {
|
} catch {
|
||||||
status: "pending_ocr",
|
const summary = {
|
||||||
created_at: { lte: minutesAgo(minutes) },
|
mode: "pending-ocr-readiness",
|
||||||
|
status: "FAIL",
|
||||||
|
findingCount: 1,
|
||||||
|
findings: [{ code: "pending_ocr_check_failed", severity: "blocker" }],
|
||||||
|
thresholds: [],
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const count = await prisma.invoicePool.count({ where });
|
printSummary(summary, jsonOnly);
|
||||||
const rows = await prisma.invoicePool.findMany({
|
console.error("[pending-ocr][FAIL] database check failed");
|
||||||
where,
|
process.exitCode = 1;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
console.error(`[pending-ocr][FAIL] ${error instanceof Error ? error.message : String(error)}`);
|
if (isMain) {
|
||||||
process.exit(1);
|
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 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 focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/;
|
||||||
const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"];
|
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) {
|
function normalizeBaseUrl(value) {
|
||||||
return String(value).replace(/\/+$/, "");
|
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) {
|
function toAssetPath(value) {
|
||||||
if (value.startsWith("/")) {
|
if (value.startsWith("/")) {
|
||||||
return value;
|
return value;
|
||||||
@@ -27,6 +37,7 @@ async function fetchText(path) {
|
|||||||
async function collectAssets() {
|
async function collectAssets() {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const queue = [];
|
const queue = [];
|
||||||
|
const sources = new Map();
|
||||||
const html = await fetchText("/");
|
const html = await fetchText("/");
|
||||||
|
|
||||||
for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) {
|
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) {
|
for (let index = 0; index < queue.length; index += 1) {
|
||||||
const assetPath = queue[index];
|
const assetPath = queue[index];
|
||||||
const source = await fetchText(assetPath);
|
const source = await fetchText(assetPath);
|
||||||
|
sources.set(assetPath, source);
|
||||||
for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) {
|
for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) {
|
||||||
const nextPath = toAssetPath(match[1]);
|
const nextPath = toAssetPath(match[1]);
|
||||||
if (!seen.has(nextPath)) {
|
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 = [];
|
const findings = [];
|
||||||
|
let expectedBuildShaFound = expectedBuildSha === null;
|
||||||
|
let forbiddenFullBuildShaFound = false;
|
||||||
|
|
||||||
for (const assetPath of assets) {
|
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)) {
|
if (!focusedAssetPattern.test(assetPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = await fetchText(assetPath);
|
|
||||||
for (const term of forbiddenTerms) {
|
for (const term of forbiddenTerms) {
|
||||||
if (source.includes(term)) {
|
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) {
|
for (const finding of findings) {
|
||||||
console.log(`ERROR ${finding.assetPath} contains ${finding.term}`);
|
console.log(`ERROR ${finding.assetPath} category=${finding.category}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (findings.length) {
|
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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
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 relativePath = `${taskDir}/${action}.jsonl`;
|
||||||
|
const policy = contextManifestPolicy(dispatchMode);
|
||||||
if (!exists(relativePath)) {
|
if (!exists(relativePath)) {
|
||||||
|
if (policy.required) {
|
||||||
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const entries = parseJsonl(relativePath);
|
const entries = parseJsonl(relativePath);
|
||||||
let realEntryCount = 0;
|
let realEntryCount = 0;
|
||||||
for (const { value, line } of entries) {
|
for (const { value, line } of entries) {
|
||||||
if (value._example) {
|
if (value._example) {
|
||||||
|
if (policy.rejectSeedRows) {
|
||||||
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
|
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof value.file !== "string" || !value.file.trim()) {
|
if (typeof value.file !== "string" || !value.file.trim()) {
|
||||||
@@ -117,8 +134,10 @@ function checkContextManifest(result, taskDir, action) {
|
|||||||
}
|
}
|
||||||
realEntryCount += 1;
|
realEntryCount += 1;
|
||||||
}
|
}
|
||||||
if (realEntryCount === 0) {
|
if (realEntryCount === 0 && policy.requireRealEntries) {
|
||||||
fail(result, `task.${action}_empty`, `${relativePath} must contain at least one real context entry`);
|
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) {
|
function checkTaskContracts(result) {
|
||||||
const taskDirs = listTaskDirs();
|
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) {
|
if (taskDirs.length === 0) {
|
||||||
fail(result, "tasks.none", "no .trellis/tasks/* directories found");
|
fail(result, "tasks.none", "no .trellis/tasks/* directories found");
|
||||||
return;
|
return;
|
||||||
@@ -227,8 +248,8 @@ function checkTaskContracts(result) {
|
|||||||
if (complexTaskPattern.test(complexText) && !exists(`${dir}/design.md`) && !exists(`${dir}/implement.md`)) {
|
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`);
|
fail(result, "task.complex_design", `${dir} complex task must include design.md or implement.md`);
|
||||||
}
|
}
|
||||||
checkContextManifest(result, dir, "implement");
|
checkContextManifest(result, dir, "implement", dispatchMode);
|
||||||
checkContextManifest(result, dir, "check");
|
checkContextManifest(result, dir, "check", dispatchMode);
|
||||||
}
|
}
|
||||||
if (result.status === "PASS") pass(result, "task_contracts", `${taskDirs.length} task directories have Trellis contracts`);
|
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`);
|
if (result.status === "PASS") pass(result, "sensitive_scan", `${scanFiles.length} Trellis files scanned`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = createResult();
|
export function runGovernanceChecks() {
|
||||||
try {
|
const result = createResult();
|
||||||
|
try {
|
||||||
checkCoreFiles(result);
|
checkCoreFiles(result);
|
||||||
checkTaskContracts(result);
|
checkTaskContracts(result);
|
||||||
checkDocsRegistration(result);
|
checkDocsRegistration(result);
|
||||||
checkRemovedProjectSkillReferences(result);
|
checkRemovedProjectSkillReferences(result);
|
||||||
checkSensitiveData(result);
|
checkSensitiveData(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(result, "exception", error instanceof Error ? error.message : String(error));
|
fail(result, "exception", error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
process.exit(result.status === "PASS" ? 0 : 1);
|
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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -u -o pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
CLIENT_DIR="$ROOT_DIR/client"
|
CLIENT_DIR="$ROOT_DIR/client"
|
||||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
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_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"
|
mkdir -p "$REPORT_DIR"
|
||||||
|
|
||||||
write_summary() {
|
write_step() {
|
||||||
local exit_code="$?"
|
local name="$1"
|
||||||
local status="PASS"
|
local status="$2"
|
||||||
if [[ "$exit_code" -ne 0 ]]; then
|
local kind="$3"
|
||||||
status="FAIL"
|
local code="$4"
|
||||||
fi
|
local finding_count="$5"
|
||||||
QA_REPORT_DIR="$REPORT_DIR" \
|
local detail="$6"
|
||||||
QA_ISSUE_ID="${QA_ISSUE_ID:-}" \
|
local artifact_file="${7:-}"
|
||||||
QA_BASE_URL="$PUBLIC_BASE_URL" \
|
node "$REPORT_TOOL" write-step \
|
||||||
QA_MODE="live-smoke-readiness" \
|
"$ROOT_DIR" "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
has_live_smoke_credentials() {
|
||||||
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
||||||
@@ -54,39 +138,66 @@ has_live_ocr_sample() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
echo ">>> [live-readiness] 环境与公网入口检查"
|
run_json_step \
|
||||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$REPORT_DIR/env.json"
|
"environment" \
|
||||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs"
|
"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"
|
run_command_step \
|
||||||
curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
|
"publicReady" \
|
||||||
|
"required" \
|
||||||
|
"public readiness endpoint" \
|
||||||
|
curl -fsS "$PUBLIC_BASE_URL/ready"
|
||||||
|
|
||||||
echo ">>> [live-readiness] 公网资源文案扫描"
|
run_command_step \
|
||||||
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt"
|
"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 扫描"
|
run_json_step \
|
||||||
node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 | tee "$REPORT_DIR/pending-ocr.txt"
|
"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
|
if has_live_smoke_credentials; then
|
||||||
echo ">>> [live-readiness] 执行公网 live smoke"
|
run_command_step \
|
||||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
|
"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
|
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
|
fi
|
||||||
|
|
||||||
if has_live_ocr_credentials && has_live_ocr_sample; then
|
if has_live_ocr_credentials && has_live_ocr_sample; then
|
||||||
echo ">>> [live-readiness] 执行真实 OCR 样本复核"
|
run_command_step \
|
||||||
(
|
"liveOcr" \
|
||||||
cd "$CLIENT_DIR"
|
"required" \
|
||||||
PLAYWRIGHT_MANUAL=1 \
|
"live OCR sample verification" \
|
||||||
PLAYWRIGHT_OCR_LIVE=1 \
|
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"
|
||||||
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"
|
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
echo "[live-readiness][PASS] readiness 检查完成"
|
if node "$REPORT_TOOL" privacy "$ROOT_DIR" "$REPORT_DIR" >"$REPORT_DIR/privacy.log" 2>&1; then
|
||||||
echo "[live-readiness] report=$REPORT_DIR"
|
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"
|
||||||
|
|||||||
+13
-13
@@ -104,8 +104,10 @@ function setStaticCacheHeaders(res, filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildApp() {
|
async function buildApp(dependencies = {}) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
const prismaPluginImpl = dependencies.prismaPlugin ?? prismaPlugin;
|
||||||
|
const startJobsImpl = dependencies.startJobs ?? startJobs;
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -246,7 +248,7 @@ async function buildApp() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(prismaPlugin);
|
await app.register(prismaPluginImpl);
|
||||||
await app.register(authPlugin);
|
await app.register(authPlugin);
|
||||||
|
|
||||||
if (config.nodeEnv !== "production" && config.openapiDocsEnabled) {
|
if (config.nodeEnv !== "production" && config.openapiDocsEnabled) {
|
||||||
@@ -254,10 +256,6 @@ async function buildApp() {
|
|||||||
app.log.info("OpenAPI docs enabled at /api/docs");
|
app.log.info("OpenAPI docs enabled at /api/docs");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.otelTracesEnabled) {
|
|
||||||
app.log.info("OTel tracing enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/health",
|
"/health",
|
||||||
{
|
{
|
||||||
@@ -267,16 +265,17 @@ async function buildApp() {
|
|||||||
response: {
|
response: {
|
||||||
200: buildDataEnvelopeSchema({
|
200: buildDataEnvelopeSchema({
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["ok"],
|
required: ["ok", "buildSha"],
|
||||||
properties: {
|
properties: {
|
||||||
ok: { type: "boolean" },
|
ok: { type: "boolean" },
|
||||||
|
buildSha: { type: "string" },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
return reply.send({ requestId: request.id, data: { ok: true } });
|
return reply.send({ requestId: request.id, data: { ok: true, buildSha: app.config.buildSha } });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -289,16 +288,17 @@ async function buildApp() {
|
|||||||
response: {
|
response: {
|
||||||
200: buildDataEnvelopeSchema({
|
200: buildDataEnvelopeSchema({
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["ok"],
|
required: ["ok", "buildSha"],
|
||||||
properties: {
|
properties: {
|
||||||
ok: { type: "boolean" },
|
ok: { type: "boolean" },
|
||||||
|
buildSha: { type: "string" },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
503: buildErrorEnvelopeSchema({
|
503: buildErrorEnvelopeSchema({
|
||||||
description: "数据库不可用",
|
description: "数据库不可用",
|
||||||
codeExample: "SYS_503",
|
codeExample: "SYS_503",
|
||||||
messageExample: "Service Unavailable",
|
messageExample: "Service Unavailable",
|
||||||
detailsExample: { db: "unreachable" },
|
detailsExample: { db: "unreachable", buildSha: "abcdef123456" },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -306,14 +306,14 @@ async function buildApp() {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await app.prisma.$queryRaw`SELECT 1`;
|
await app.prisma.$queryRaw`SELECT 1`;
|
||||||
return reply.send({ requestId: request.id, data: { ok: true } });
|
return reply.send({ requestId: request.id, data: { ok: true, buildSha: app.config.buildSha } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
|
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "SYS_503",
|
code: "SYS_503",
|
||||||
message: "Service Unavailable",
|
message: "Service Unavailable",
|
||||||
statusCode: 503,
|
statusCode: 503,
|
||||||
details: { db: "unreachable" },
|
details: { db: "unreachable", buildSha: app.config.buildSha },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -399,7 +399,7 @@ async function buildApp() {
|
|||||||
await app.register(aiRoutes, { prefix: "/api" });
|
await app.register(aiRoutes, { prefix: "/api" });
|
||||||
await app.register(exportsRoutes, { prefix: "/api" });
|
await app.register(exportsRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
await startJobs(app);
|
await startJobsImpl(app);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ function requiredEnv(name) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBuildSha(value) {
|
||||||
|
const sha = String(value || "").trim().toLowerCase();
|
||||||
|
return /^[0-9a-f]{7,64}$/.test(sha) ? sha.slice(0, 12) : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
const nodeEnv = process.env.NODE_ENV ?? "development";
|
const nodeEnv = process.env.NODE_ENV ?? "development";
|
||||||
const serverRoot = path.resolve(__dirname, "..");
|
const serverRoot = path.resolve(__dirname, "..");
|
||||||
@@ -21,6 +26,7 @@ function getConfig() {
|
|||||||
nodeEnv,
|
nodeEnv,
|
||||||
port: Number(process.env.PORT ?? 3000),
|
port: Number(process.env.PORT ?? 3000),
|
||||||
host: process.env.HOST ?? "0.0.0.0",
|
host: process.env.HOST ?? "0.0.0.0",
|
||||||
|
buildSha: normalizeBuildSha(process.env.BUILD_SHA),
|
||||||
|
|
||||||
metricsEnabled: process.env.METRICS_ENABLED !== "false",
|
metricsEnabled: process.env.METRICS_ENABLED !== "false",
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const fp = require("fastify-plugin");
|
||||||
|
|
||||||
|
const { buildApp } = require("../../src/app");
|
||||||
|
|
||||||
|
const startJobs = vi.fn(async () => ({ driver: "disabled" }));
|
||||||
|
|
||||||
|
function createPrismaPlugin(prisma) {
|
||||||
|
return fp(async (app) => {
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
}, { name: "test-prisma" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildHealthApp({ buildSha, queryRaw = vi.fn().mockResolvedValue([{ ok: 1 }]) }) {
|
||||||
|
vi.stubEnv("NODE_ENV", "test");
|
||||||
|
vi.stubEnv("JWT_SECRET", "test-secret");
|
||||||
|
vi.stubEnv("DATABASE_URL", "");
|
||||||
|
vi.stubEnv("BUILD_SHA", buildSha);
|
||||||
|
vi.stubEnv("JOBS_ENABLED", "false");
|
||||||
|
vi.stubEnv("METRICS_ENABLED", "false");
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const app = await buildApp({
|
||||||
|
prismaPlugin: createPrismaPlugin(prisma),
|
||||||
|
startJobs,
|
||||||
|
});
|
||||||
|
return { app, queryRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("app build provenance endpoints", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same redacted build SHA from health and readiness", async () => {
|
||||||
|
const { app } = await buildHealthApp({
|
||||||
|
buildSha: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await app.inject({ method: "GET", url: "/health" });
|
||||||
|
const ready = await app.inject({ method: "GET", url: "/ready" });
|
||||||
|
|
||||||
|
expect(health.statusCode).toBe(200);
|
||||||
|
expect(ready.statusCode).toBe(200);
|
||||||
|
expect(health.json().data).toEqual({ ok: true, buildSha: "abcdef123456" });
|
||||||
|
expect(ready.json().data).toEqual({ ok: true, buildSha: "abcdef123456" });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("uses a non-identifying marker when no valid build SHA is supplied", async () => {
|
||||||
|
const { app } = await buildHealthApp({ buildSha: "local-working-tree" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await app.inject({ method: "GET", url: "/health" });
|
||||||
|
expect(health.json().data.buildSha).toBe("unknown");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("keeps the build marker visible when readiness fails", async () => {
|
||||||
|
const queryRaw = vi.fn().mockRejectedValueOnce(new Error("database unavailable"));
|
||||||
|
const { app } = await buildHealthApp({
|
||||||
|
buildSha: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||||
|
queryRaw,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await app.inject({ method: "GET", url: "/ready" });
|
||||||
|
expect(ready.statusCode).toBe(503);
|
||||||
|
expect(ready.json().error.details).toEqual({
|
||||||
|
db: "unreachable",
|
||||||
|
buildSha: "abcdef123456",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
@@ -1,32 +1,23 @@
|
|||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const fp = require("fastify-plugin");
|
||||||
const createPrismaClient = vi.fn(() => ({
|
|
||||||
prisma: {
|
|
||||||
$connect: vi.fn().mockResolvedValue(undefined),
|
|
||||||
$disconnect: vi.fn().mockResolvedValue(undefined),
|
|
||||||
employee: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pool: {
|
|
||||||
end: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const startJobs = vi.fn(async () => ({ driver: "disabled" }));
|
const startJobs = vi.fn(async () => ({ driver: "disabled" }));
|
||||||
|
|
||||||
vi.mock("../../src/prismaClient", () => ({
|
|
||||||
createPrismaClient,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../src/jobs/jobSystem", () => ({
|
|
||||||
startJobs,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { buildApp } = require("../../src/app");
|
const { buildApp } = require("../../src/app");
|
||||||
|
|
||||||
|
function createPrismaPlugin() {
|
||||||
|
return fp(async (app) => {
|
||||||
|
app.decorate("prisma", {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]),
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, { name: "test-prisma" });
|
||||||
|
}
|
||||||
|
|
||||||
async function createClientDistFixture() {
|
async function createClientDistFixture() {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-static-cache-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-static-cache-"));
|
||||||
await fs.mkdir(path.join(tempDir, "assets"), { recursive: true });
|
await fs.mkdir(path.join(tempDir, "assets"), { recursive: true });
|
||||||
@@ -46,8 +37,12 @@ async function buildStaticApp(clientDistDir) {
|
|||||||
vi.stubEnv("CLIENT_DIST_DIR", clientDistDir);
|
vi.stubEnv("CLIENT_DIST_DIR", clientDistDir);
|
||||||
vi.stubEnv("STORAGE_DIR", path.join(clientDistDir, "..", "storage"));
|
vi.stubEnv("STORAGE_DIR", path.join(clientDistDir, "..", "storage"));
|
||||||
vi.stubEnv("JOBS_ENABLED", "false");
|
vi.stubEnv("JOBS_ENABLED", "false");
|
||||||
|
vi.stubEnv("DATABASE_URL", "");
|
||||||
|
|
||||||
return buildApp();
|
return buildApp({
|
||||||
|
prismaPlugin: createPrismaPlugin(),
|
||||||
|
startJobs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("app static cache headers", () => {
|
describe("app static cache headers", () => {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
|
||||||
|
let pendingOcr;
|
||||||
|
|
||||||
|
describe("pending OCR readiness contract", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const scriptPath = path.resolve(__dirname, "../../../../scripts/qa/check-pending-ocr.mjs");
|
||||||
|
pendingOcr = await import(pathToFileURL(scriptPath).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes unique positive thresholds", () => {
|
||||||
|
expect(pendingOcr.parseThresholds(["--thresholds=10,2,5,2,0,invalid"])).toEqual([2, 5, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a non-clean SKIP when the database target is unavailable", async () => {
|
||||||
|
const summary = await pendingOcr.buildPendingOcrSummary({
|
||||||
|
databaseReady: false,
|
||||||
|
thresholds: [2, 5, 10],
|
||||||
|
countPending: vi.fn(),
|
||||||
|
now: new Date("2026-07-15T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
status: "SKIP",
|
||||||
|
findingCount: 1,
|
||||||
|
findings: [{ code: "database_not_configured", severity: "manual_or_config" }],
|
||||||
|
thresholds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports PASS when no pending OCR rows exceed any threshold", async () => {
|
||||||
|
const countPending = vi.fn(async () => 0);
|
||||||
|
const summary = await pendingOcr.buildPendingOcrSummary({
|
||||||
|
databaseReady: true,
|
||||||
|
thresholds: [2, 5],
|
||||||
|
countPending,
|
||||||
|
now: new Date("2026-07-15T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({ status: "PASS", findingCount: 0, findings: [] });
|
||||||
|
expect(summary.thresholds).toEqual([
|
||||||
|
{ thresholdMinutes: 2, count: 0 },
|
||||||
|
{ thresholdMinutes: 5, count: 0 },
|
||||||
|
]);
|
||||||
|
expect(countPending).toHaveBeenNthCalledWith(1, {
|
||||||
|
status: "pending_ocr",
|
||||||
|
created_at: { lte: new Date("2026-07-14T23:58:00.000Z") },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports PASS_WITH_FINDINGS using the unique pending row upper bound", async () => {
|
||||||
|
const counts = [2, 4, 7];
|
||||||
|
const summary = await pendingOcr.buildPendingOcrSummary({
|
||||||
|
databaseReady: true,
|
||||||
|
thresholds: [2, 5, 10],
|
||||||
|
countPending: vi.fn(async () => counts.shift()),
|
||||||
|
now: new Date("2026-07-15T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
status: "PASS_WITH_FINDINGS",
|
||||||
|
findingCount: 7,
|
||||||
|
findings: [{ code: "pending_ocr_detected", severity: "finding", count: 7 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs and closes the shared Prisma 7 adapter without opening a database connection", async () => {
|
||||||
|
const previousDatabaseUrl = process.env.DATABASE_URL;
|
||||||
|
process.env.DATABASE_URL = "postgresql://local-user:[email protected]:1/hyb_oa_test";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(pendingOcr.executePendingOcrCheck({
|
||||||
|
databaseReady: true,
|
||||||
|
thresholds: [],
|
||||||
|
})).resolves.toMatchObject({ status: "PASS", findingCount: 0 });
|
||||||
|
} finally {
|
||||||
|
if (previousDatabaseUrl === undefined) {
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_URL = previousDatabaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
|
||||||
|
const ROOT_DIR = path.resolve(__dirname, "../../../..");
|
||||||
|
let scanner;
|
||||||
|
|
||||||
|
describe("current fact-source sensitive content gate", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const scriptPath = path.join(ROOT_DIR, "scripts/qa/check-sensitive-content.mjs");
|
||||||
|
scanner = await import(pathToFileURL(scriptPath).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports only redacted metadata for secret, account, path and infrastructure matches", () => {
|
||||||
|
const source = [
|
||||||
|
"SERVICE_PASSWORD=do-not-disclose-value",
|
||||||
|
"[email protected]",
|
||||||
|
"phone=13800000000",
|
||||||
|
"workspace=/home/developer/private/project",
|
||||||
|
"server=203.0.113.10",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const findings = scanner.scanSensitiveText(source, "fixture.md");
|
||||||
|
|
||||||
|
expect(findings.map((item) => item.category)).toEqual(expect.arrayContaining([
|
||||||
|
"assigned_secret",
|
||||||
|
"email_account",
|
||||||
|
"phone_account",
|
||||||
|
"personal_workspace_path",
|
||||||
|
"public_ipv4",
|
||||||
|
]));
|
||||||
|
expect(JSON.stringify(findings)).not.toMatch(/do-not-disclose|person@|13800000000|203\.0\.113\.10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit placeholders, environment interpolation and loopback addresses", () => {
|
||||||
|
const source = [
|
||||||
|
"SERVICE_PASSWORD=<managed-secret>",
|
||||||
|
"DATABASE_URL=${LOCAL_DATABASE_URL}",
|
||||||
|
"admin=<admin-email>",
|
||||||
|
"workspace=<repo-root>/server",
|
||||||
|
"server=127.0.0.1",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(scanner.scanSensitiveText(source, "fixture.md")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives current docs from the status matrix without pulling historical issue rows", () => {
|
||||||
|
const matrix = [
|
||||||
|
"| ID | Contract | Type | Purpose | Status |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
"| `CURRENT-DOC` | `docs/current.md` | fact | current | `CURRENT` |",
|
||||||
|
"| `HISTORY-DOC` | `docs/history.md` | evidence | old | `EVIDENCE` |",
|
||||||
|
"| `CURRENT-ISSUE` | `issues/current.csv` | `CURRENT` | issue only |",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(scanner.extractCurrentDocumentFiles(matrix)).toEqual(["docs/current.md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes every CURRENT docs contract in the default scan set", () => {
|
||||||
|
const files = scanner.resolveDefaultScanFiles(ROOT_DIR);
|
||||||
|
|
||||||
|
expect(files).toEqual(expect.arrayContaining([
|
||||||
|
"docs/测试规约与回归门禁.md",
|
||||||
|
"docs/需求-资产管理规约.md",
|
||||||
|
"docs/管理员手册.md",
|
||||||
|
"docs/员工快速上手.md",
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the registered current fact sources and deployment entrypoints", () => {
|
||||||
|
expect(scanner.scanSensitiveContent({ rootDir: ROOT_DIR })).toMatchObject({
|
||||||
|
status: "PASS",
|
||||||
|
findingCount: 0,
|
||||||
|
filesScanned: expect.any(Number),
|
||||||
|
findings: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
const { mkdtemp, rm, writeFile } = require("node:fs/promises");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { promisify } = require("node:util");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const ROOT_DIR = path.resolve(__dirname, "../../../..");
|
||||||
|
let provenance;
|
||||||
|
let repoDir;
|
||||||
|
|
||||||
|
async function git(args, cwd = repoDir) {
|
||||||
|
return execFileAsync("git", args, { cwd });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCommit(message = "initial") {
|
||||||
|
await git(["add", "tracked.txt"]);
|
||||||
|
await git(["commit", "-m", message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deployment source provenance", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const scriptPath = path.join(ROOT_DIR, "deploy/trialrun/check-source-provenance.mjs");
|
||||||
|
provenance = await import(pathToFileURL(scriptPath).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
repoDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-test-"));
|
||||||
|
await git(["init", "--initial-branch=main"]);
|
||||||
|
await git(["config", "user.name", "Test User"]);
|
||||||
|
await git(["config", "user.email", "[email protected]"]);
|
||||||
|
await writeFile(path.join(repoDir, "tracked.txt"), "initial\n", "utf8");
|
||||||
|
await createCommit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(repoDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an upstream by default and permits an explicit unpublished override", () => {
|
||||||
|
expect(() => provenance.collectSourceProvenance({ rootDir: repoDir })).toThrowError(
|
||||||
|
expect.objectContaining({ code: "upstream_missing" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provenance.collectSourceProvenance({ rootDir: repoDir, allowUnpublished: true })).toMatchObject({
|
||||||
|
status: "PASS",
|
||||||
|
published: false,
|
||||||
|
overrideUsed: true,
|
||||||
|
branch: "main",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tracked and untracked working-tree changes before any deployment", async () => {
|
||||||
|
await writeFile(path.join(repoDir, "tracked.txt"), "changed\n", "utf8");
|
||||||
|
await writeFile(path.join(repoDir, "untracked.txt"), "untracked\n", "utf8");
|
||||||
|
|
||||||
|
expect(() => provenance.collectSourceProvenance({ rootDir: repoDir, allowUnpublished: true })).toThrowError(
|
||||||
|
expect.objectContaining({ code: "dirty_worktree" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a clean commit that is contained in its upstream", async () => {
|
||||||
|
const remoteDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-remote-"));
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["init", "--bare", remoteDir]);
|
||||||
|
await git(["remote", "add", "origin", remoteDir]);
|
||||||
|
await git(["push", "--set-upstream", "origin", "main"]);
|
||||||
|
|
||||||
|
expect(provenance.collectSourceProvenance({ rootDir: repoDir })).toMatchObject({
|
||||||
|
status: "PASS",
|
||||||
|
published: true,
|
||||||
|
overrideUsed: false,
|
||||||
|
upstream: "origin/main",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(remoteDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a clean local commit that has not reached upstream", async () => {
|
||||||
|
const remoteDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-remote-"));
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["init", "--bare", remoteDir]);
|
||||||
|
await git(["remote", "add", "origin", remoteDir]);
|
||||||
|
await git(["push", "--set-upstream", "origin", "main"]);
|
||||||
|
await writeFile(path.join(repoDir, "tracked.txt"), "ahead\n", "utf8");
|
||||||
|
await createCommit("ahead");
|
||||||
|
|
||||||
|
expect(() => provenance.collectSourceProvenance({ rootDir: repoDir })).toThrowError(
|
||||||
|
expect.objectContaining({ code: "commit_not_in_upstream" }),
|
||||||
|
);
|
||||||
|
expect(provenance.collectSourceProvenance({ rootDir: repoDir, allowUnpublished: true })).toMatchObject({
|
||||||
|
published: false,
|
||||||
|
overrideUsed: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(remoteDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the remote-tracking ref before deciding whether a commit is still published", async () => {
|
||||||
|
const remoteDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-remote-"));
|
||||||
|
const replacementDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-replacement-"));
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["init", "--bare", remoteDir]);
|
||||||
|
await git(["remote", "add", "origin", remoteDir]);
|
||||||
|
await git(["push", "--set-upstream", "origin", "main"]);
|
||||||
|
expect(provenance.collectSourceProvenance({ rootDir: repoDir })).toMatchObject({ published: true });
|
||||||
|
|
||||||
|
await execFileAsync("git", ["init", "--initial-branch=main"], { cwd: replacementDir });
|
||||||
|
await execFileAsync("git", ["config", "user.name", "Replacement"], { cwd: replacementDir });
|
||||||
|
await execFileAsync("git", ["config", "user.email", "[email protected]"], { cwd: replacementDir });
|
||||||
|
await writeFile(path.join(replacementDir, "replacement.txt"), "replacement\n", "utf8");
|
||||||
|
await execFileAsync("git", ["add", "replacement.txt"], { cwd: replacementDir });
|
||||||
|
await execFileAsync("git", ["commit", "-m", "replacement"], { cwd: replacementDir });
|
||||||
|
await execFileAsync("git", ["remote", "add", "origin", remoteDir], { cwd: replacementDir });
|
||||||
|
await execFileAsync("git", ["push", "--force", "origin", "main"], { cwd: replacementDir });
|
||||||
|
|
||||||
|
expect(provenance.collectSourceProvenance({ rootDir: repoDir })).toMatchObject({ published: true });
|
||||||
|
expect(provenance.refreshUpstreamRef({ rootDir: repoDir })).toMatchObject({
|
||||||
|
refreshed: true,
|
||||||
|
upstream: "origin/main",
|
||||||
|
remote: "origin",
|
||||||
|
});
|
||||||
|
expect(() => provenance.collectSourceProvenance({ rootDir: repoDir })).toThrowError(
|
||||||
|
expect.objectContaining({ code: "commit_not_in_upstream" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(remoteDir, { recursive: true, force: true });
|
||||||
|
await rm(replacementDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an allowlisted audit reason for the unpublished override", async () => {
|
||||||
|
const script = path.join(ROOT_DIR, "deploy/trialrun/check-source-provenance.mjs");
|
||||||
|
|
||||||
|
await expect(execFileAsync("node", [script, `--root=${repoDir}`], {
|
||||||
|
env: { ...process.env, DEPLOY_ALLOW_UNPUBLISHED_COMMIT: "1" },
|
||||||
|
})).rejects.toMatchObject({ code: 1 });
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync("node", [script, `--root=${repoDir}`], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEPLOY_ALLOW_UNPUBLISHED_COMMIT: "1",
|
||||||
|
DEPLOY_UNPUBLISHED_REASON: "offline-disaster-recovery",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.parse(stdout)).toMatchObject({
|
||||||
|
published: false,
|
||||||
|
remoteVerified: false,
|
||||||
|
overrideUsed: true,
|
||||||
|
overrideRequested: true,
|
||||||
|
overrideReason: "offline-disaster-recovery",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
|
||||||
|
let governance;
|
||||||
|
|
||||||
|
describe("Trellis context manifest governance", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const scriptPath = path.resolve(__dirname, "../../../../scripts/qa/check-trellis-governance.mjs");
|
||||||
|
governance = await import(pathToFileURL(scriptPath).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require sub-agent manifests in Codex inline mode", () => {
|
||||||
|
expect(governance.contextManifestPolicy("inline")).toEqual({
|
||||||
|
required: false,
|
||||||
|
requireRealEntries: false,
|
||||||
|
rejectSeedRows: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps strict manifest validation for sub-agent dispatch", () => {
|
||||||
|
expect(governance.contextManifestPolicy("sub-agent")).toEqual({
|
||||||
|
required: true,
|
||||||
|
requireRealEntries: true,
|
||||||
|
rejectSeedRows: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
const { mkdtemp, readFile, rm } = require("node:fs/promises");
|
||||||
|
const http = require("node:http");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { promisify } = require("node:util");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const ROOT_DIR = path.resolve(__dirname, "../../../..");
|
||||||
|
let readinessReport;
|
||||||
|
|
||||||
|
function passSteps() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
readinessReport.READINESS_STEP_NAMES.map((name) => [name, {
|
||||||
|
status: "PASS",
|
||||||
|
kind: "required",
|
||||||
|
findingCount: 0,
|
||||||
|
logRef: `artifacts/qa-flow/test/${name}.log`,
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
server.off("error", reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(server) {
|
||||||
|
return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPECTED_BUILD_SHA = "abcdef123456";
|
||||||
|
|
||||||
|
describe("live smoke readiness aggregation", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const scriptPath = path.join(ROOT_DIR, "scripts/qa/live-smoke-readiness-report.mjs");
|
||||||
|
readinessReport = await import(pathToFileURL(scriptPath).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["missing database", "pendingOcr"],
|
||||||
|
["missing login credentials", "liveSmoke"],
|
||||||
|
["missing OCR sample", "liveOcr"],
|
||||||
|
])("propagates %s as PASS_WITH_FINDINGS", (_label, stepName) => {
|
||||||
|
const steps = passSteps();
|
||||||
|
steps[stepName] = {
|
||||||
|
...steps[stepName],
|
||||||
|
status: "SKIP",
|
||||||
|
findingCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = readinessReport.evaluateReadinessSteps(steps);
|
||||||
|
|
||||||
|
expect(result.status).toBe("PASS_WITH_FINDINGS");
|
||||||
|
expect(result.findingCount).toBeGreaterThan(0);
|
||||||
|
expect(result.findings).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ source: stepName, severity: "manual_or_config" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports clean PASS only when every prerequisite and step is ready", () => {
|
||||||
|
expect(readinessReport.evaluateReadinessSteps(passSteps())).toMatchObject({
|
||||||
|
status: "PASS",
|
||||||
|
findingCount: 0,
|
||||||
|
findings: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a required command failure as a blocker", () => {
|
||||||
|
const steps = passSteps();
|
||||||
|
steps.publicReady = { ...steps.publicReady, status: "FAIL", findingCount: 1 };
|
||||||
|
|
||||||
|
expect(readinessReport.evaluateReadinessSteps(steps)).toMatchObject({
|
||||||
|
status: "FAIL",
|
||||||
|
findingCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the shell parent against a local public stub and writes a redacted non-clean summary", async () => {
|
||||||
|
const reportDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-readiness-test-"));
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
||||||
|
if (request.url === "/ready") {
|
||||||
|
response.end(JSON.stringify({ data: { ok: true, buildSha: EXPECTED_BUILD_SHA } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === "/api/auth/wecom/config") {
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
data: {
|
||||||
|
status: "ready",
|
||||||
|
entryEnabled: true,
|
||||||
|
oauthEnabled: true,
|
||||||
|
defaultReturnTo: "/portal",
|
||||||
|
loginUrl: "/api/auth/wecom/login",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === "/api/auth/capabilities") {
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
data: {
|
||||||
|
emailDelivery: { ready: true, statusReasons: [] },
|
||||||
|
emailLoginCode: { enabled: true },
|
||||||
|
passwordReset: { enabled: true },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === "/") {
|
||||||
|
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||||
|
response.end('<!doctype html><html><body>OA<script src="/assets/index-test.js"></script></body></html>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === "/assets/index-test.js") {
|
||||||
|
response.setHeader("content-type", "application/javascript; charset=utf-8");
|
||||||
|
response.end(`document.documentElement.dataset.buildSha="${EXPECTED_BUILD_SHA}";`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end(JSON.stringify({ error: "not_found" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await listen(server);
|
||||||
|
const address = server.address();
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const key of [
|
||||||
|
"DATABASE_URL",
|
||||||
|
"LIVE_SMOKE_ADMIN_IDENTIFIER",
|
||||||
|
"LIVE_SMOKE_ADMIN_PASSWORD",
|
||||||
|
"LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
|
||||||
|
"LIVE_SMOKE_EMPLOYEE_PASSWORD",
|
||||||
|
"PLAYWRIGHT_OCR_LIVE_IDENTIFIER",
|
||||||
|
"PLAYWRIGHT_OCR_LIVE_PASSWORD",
|
||||||
|
"PLAYWRIGHT_OCR_LIVE_FILE",
|
||||||
|
"PLAYWRIGHT_OCR_LIVE_DIR",
|
||||||
|
"LIVE_OCR_INVOICE_FILE",
|
||||||
|
]) {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
|
env.LIVE_SMOKE_BASE_URL = `http://127.0.0.1:${address.port}`;
|
||||||
|
env.LIVE_SMOKE_READINESS_DIR = reportDir;
|
||||||
|
env.LIVE_SMOKE_EXPECTED_BUILD_SHA = EXPECTED_BUILD_SHA;
|
||||||
|
env.QA_ISSUE_ID = "READINESS-TEST";
|
||||||
|
|
||||||
|
const result = await execFileAsync("bash", [path.join(ROOT_DIR, "scripts/qa/run-live-smoke-readiness.sh")], {
|
||||||
|
cwd: ROOT_DIR,
|
||||||
|
env,
|
||||||
|
maxBuffer: 2 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const summaryText = await readFile(path.join(reportDir, "summary.json"), "utf8");
|
||||||
|
const summary = JSON.parse(summaryText);
|
||||||
|
|
||||||
|
expect(result.stdout).toContain("[live-readiness][PASS_WITH_FINDINGS]");
|
||||||
|
expect(summary.status).toBe("PASS_WITH_FINDINGS");
|
||||||
|
expect(summary.findingCount).toBeGreaterThan(0);
|
||||||
|
expect(summary.steps.pendingOcr.status).toBe("SKIP");
|
||||||
|
expect(summary.steps.liveSmoke.status).toBe("SKIP");
|
||||||
|
expect(summary.steps.liveOcr.status).toBe("SKIP");
|
||||||
|
expect(summary.steps.privacy.status).toBe("PASS");
|
||||||
|
expect(summary.expectedBuildSha).toBe(EXPECTED_BUILD_SHA);
|
||||||
|
expect(summary.observedBuildSha).toBe(EXPECTED_BUILD_SHA);
|
||||||
|
expect(summary.buildShaMatches).toBe(true);
|
||||||
|
expect(summary.childReports.pendingOcr.findingCount).toBeGreaterThan(0);
|
||||||
|
expect(summaryText).not.toContain(ROOT_DIR);
|
||||||
|
expect(summaryText).not.toMatch(/DATABASE_URL|LIVE_SMOKE_ADMIN_PASSWORD|PLAYWRIGHT_OCR_LIVE_FILE/);
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
await rm(reportDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户