完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)

这个提交包含在:
2026-07-16 05:17:38 +08:00
父节点 abb6a22f5c
当前提交 28e8e746b7
共修改 29 个文件,包含 1849 行新增和 233 行删除
+2
查看文件
@@ -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
+143
查看文件
@@ -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();
}
+8 -7
查看文件
@@ -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`
+77 -20
查看文件
@@ -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,12 +190,17 @@ 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
ready_ok=1 if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e '
break const payload = JSON.parse(process.env.READY_PAYLOAD || "{}");
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
'; then
ready_ok=1
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"
+12
查看文件
@@ -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` 记录。
+32 -4
查看文件
@@ -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
+49 -3
查看文件
@@ -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);
} }
} }
+116 -65
查看文件
@@ -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(
.split(",") raw
.map((item) => Number(item.trim())) .split(",")
.filter((item) => Number.isFinite(item) && item > 0) .map((item) => Number(item.trim()))
.sort((a, b) => a - b); .filter((item) => Number.isFinite(item) && item > 0)
.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",
const count = await prisma.invoicePool.count({ where }); findingCount: 1,
const rows = await prisma.invoicePool.findMany({ findings: [{ code: "pending_ocr_check_failed", severity: "blocker" }],
where, thresholds: [],
orderBy: { created_at: "asc" }, checkedAt: new Date().toISOString(),
take: 50, };
select: { printSummary(summary, jsonOnly);
id: true, console.error("[pending-ocr][FAIL] database check failed");
created_at: true, process.exitCode = 1;
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();
}); }
+46 -6
查看文件
@@ -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) {
+155
查看文件
@@ -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();
}
+45 -17
查看文件
@@ -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)) {
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`); if (policy.required) {
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
} else {
pass(result, `task.${action}_inline_optional`, `${taskDir} uses inline dispatch; ${action}.jsonl is optional`);
}
return; 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) {
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`); if (policy.rejectSeedRows) {
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
}
continue; 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();
checkCoreFiles(result); try {
checkTaskContracts(result); checkCoreFiles(result);
checkDocsRegistration(result); checkTaskContracts(result);
checkRemovedProjectSkillReferences(result); checkDocsRegistration(result);
checkSensitiveData(result); checkRemovedProjectSkillReferences(result);
} catch (error) { checkSensitiveData(result);
fail(result, "exception", error instanceof Error ? error.message : String(error)); } catch (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);
}
+293
查看文件
@@ -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;
} }
+6
查看文件
@@ -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",
+87
查看文件
@@ -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);
});
+17 -22
查看文件
@@ -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);
});