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

这个提交包含在:
2026-07-16 05:17:38 +08:00
父节点 abb6a22f5c
当前提交 28e8e746b7
共修改 29 个文件,包含 1849 行新增和 233 行删除
+14 -14
查看文件
@@ -31,7 +31,7 @@ SIGNED_URL_TTL_SECONDS=600
MAX_UPLOAD_SIZE_BYTES=10485760
# ===== 服务端密钥(必须修改) =====
JWT_SECRET=change_me_in_trialrun
JWT_SECRET=<managed-jwt-secret>
FIELD_ENCRYPTION_KEY=
# ===== 密码 / 登录安全 =====
@@ -85,11 +85,11 @@ WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE=
IM_NOTIFIER_ENABLED=true
# ===== 数据库(Compose 内部连接) =====
POSTGRES_DB=hyb_oa
POSTGRES_USER=hyboa_user
POSTGRES_PASSWORD=hyboa_password
DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public
# SHADOW_DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa_shadow
POSTGRES_DB=<managed-db-name>
POSTGRES_USER=<managed-db-user>
POSTGRES_PASSWORD=<managed-db-password>
DATABASE_URL=<managed-postgresql-url>
# SHADOW_DATABASE_URL=<managed-shadow-postgresql-url>
# ===== 存储与上传 =====
STORAGE_DIR=/data/storage
@@ -97,11 +97,11 @@ STORAGE_DRIVER=local
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_BUCKET=hyb-oa
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_ACCESS_KEY_ID=<managed-s3-access-key>
S3_SECRET_ACCESS_KEY=<managed-s3-secret>
S3_FORCE_PATH_STYLE=true
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
MINIO_ROOT_USER=<managed-minio-user>
MINIO_ROOT_PASSWORD=<managed-minio-password>
# ===== OCR(off | mock | tencent) =====
OCR_PROVIDER=mock
@@ -154,7 +154,7 @@ INTEGRATION_OUTBOX_RETRY_SECONDS=120
# ===== 邮件 =====
EMAIL_ENABLED=false
EMAIL_FROM=[email protected]
EMAIL_FROM=<sender-email>
EMAIL_BASE_URL=https://oa.example.com
SMTP_HOST=
SMTP_PORT=587
@@ -177,10 +177,10 @@ AI_TIMEOUT_MS=10000
AI_AUTH_TOKEN=
# ===== Trialrun 初始化管理员 =====
TRIALRUN_ADMIN_EMAIL=admin@hyinda.com
TRIALRUN_ADMIN_PHONE=13800000000
TRIALRUN_ADMIN_EMAIL=<admin-email>
TRIALRUN_ADMIN_PHONE=<admin-phone>
TRIALRUN_ADMIN_PASSWORD=
TRIALRUN_ADMIN_EMPLOYEE_NO=T0000
TRIALRUN_ADMIN_EMPLOYEE_NO=<admin-employee-no>
TRIALRUN_ADMIN_NAME=Trialrun Admin
TRIALRUN_ADMIN_TITLE=系统管理员
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.hyinda.com { reverse_proxy 127.0.0.1:3000 }` 生效
- [ ] 已执行 `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` 或首页登录完成最小探活
- [ ] 首次上线先执行系统初始化(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`
## A. 发布前(Pre-flight)
@@ -35,10 +35,10 @@
- [ ] `./scripts/backup-storage.sh`(记录输出文件名)
## B. 发布(Deploy)
- [ ] 在本地仓库切到目标版本:`git checkout <tag>`,然后执行 `bash deploy/trialrun/deploy.sh`
- [ ] 构建并启动:
- [ ] `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 run --rm init`
- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致
- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识
- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
- [ ] 探活:
- [ ] `curl -fsS http://127.0.0.1:3000/ready`
- [ ] Runtime smoke:
@@ -49,6 +49,7 @@
- [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过
- [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION`
- [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新
- [ ] 公网 bundle 扫描确认包含匹配的 12 位构建标识,且不包含完整提交 SHA
- [ ] 登录验证已由 live smoke 与 release gate 覆盖;仅首次接入或第三方后台配置变更时补做人工确认
## C. 发布后(Post-check)
@@ -77,7 +78,7 @@
## 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 开关
- [ ] 功能开关页已将 `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`
+77 -20
查看文件
@@ -5,7 +5,7 @@
set -euo pipefail
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_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
ENABLE_WORKER="${ENABLE_WORKER:-0}"
@@ -16,6 +16,16 @@ LOCAL_ENV_FILE="deploy/trialrun/.env"
ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.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() {
command -v "$1" >/dev/null 2>&1 || {
@@ -27,6 +37,34 @@ need_cmd() {
need_cmd rsync
need_cmd ssh
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
echo "[ERROR] 缺少 ${COMPOSE_FILE}" >&2
@@ -53,13 +91,17 @@ if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then
exit 1
fi
echo ">>> 0. 校验部署环境契约"
echo ">>> 1. 校验部署环境契约"
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}'"
echo ">>> 2. 同步 OA 仓库到服务器"
echo ">>> 4. 同步已验证提交到服务器"
rsync -az --delete \
--exclude '.git' \
--exclude '.github' \
@@ -67,6 +109,7 @@ rsync -az --delete \
--exclude '.agents' \
--exclude '.codex' \
--exclude '.claude' \
--exclude '.trellis' \
--exclude 'node_modules' \
--exclude 'client/node_modules' \
--exclude 'server/node_modules' \
@@ -81,20 +124,20 @@ rsync -az --delete \
--exclude 'coverage' \
--exclude 'dist' \
--exclude '*.log' \
"${ROOT_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/"
"${STAGING_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/"
if [[ "${SYNC_ENV}" == "1" ]]; then
echo ">>> 3. 同步部署环境变量"
echo ">>> 5. 同步部署环境变量"
ssh "${DEPLOY_SERVER}" "mkdir -p '$(dirname "${REMOTE_ENV_FILE}")'"
rsync -az "${ROOT_DIR}/${LOCAL_ENV_FILE}" "${DEPLOY_SERVER}:${REMOTE_ENV_FILE}"
else
echo ">>> 3. 跳过 .env 同步(默认安全模式)"
echo ">>> 5. 跳过 .env 同步(默认安全模式)"
echo " 如果你确认本地 deploy/trialrun/.env 可直接上服务器,可用:SYNC_ENV=1 bash deploy/trialrun/deploy.sh"
fi
echo ">>> 4. 在服务器执行部署与 smoke"
echo ">>> 6. 在服务器执行部署与 smoke"
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
cd "$REMOTE_DIR"
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
fi
echo ">>> 4.6 等待 /ready"
echo ">>> 4.6 等待 /ready 并核对构建标识"
ready_ok=0
for i in $(seq 1 60); do
if curl -fsS http://127.0.0.1:3000/ready >/dev/null; then
ready_ok=1
break
if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then
if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e '
const payload = JSON.parse(process.env.READY_PAYLOAD || "{}");
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
'; then
ready_ok=1
break
fi
fi
sleep 2
done
@@ -190,18 +238,27 @@ for required_key in LIVE_SMOKE_ADMIN_IDENTIFIER LIVE_SMOKE_ADMIN_PASSWORD
done
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
else
echo ">>> 5. 跳过部署后 live smoke(未提供完整 LIVE_SMOKE_* 凭据)"
echo ">>> 7. 跳过部署后 live smoke(未提供完整凭据)"
fi
echo ">>> 6. 执行公网前端资源文案扫描"
node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
PUBLIC_BASE_URL="${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 " 远端目录: ${REMOTE_DIR}"
echo " 环境文件: ${REMOTE_ENV_FILE}"
echo ">>> 9. 执行公网前端资源文案扫描"
PUBLIC_EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" \
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 " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000"
+12
查看文件
@@ -55,6 +55,9 @@ services:
build:
context: ../..
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
restart: unless-stopped
depends_on:
@@ -64,6 +67,7 @@ services:
- ./.env
environment:
NODE_ENV: production
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
ports:
- "3000:3000"
volumes:
@@ -73,6 +77,9 @@ services:
build:
context: ../..
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
restart: unless-stopped
profiles: ["worker"]
@@ -84,6 +91,7 @@ services:
command: ["npm", "run", "worker"]
environment:
NODE_ENV: production
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
SERVE_CLIENT_DIST: "false"
JOBS_ENABLED: "true"
volumes:
@@ -93,6 +101,9 @@ services:
build:
context: ../..
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
depends_on:
db:
@@ -101,6 +112,7 @@ services:
- ./.env
environment:
NODE_ENV: production
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
volumes:
- ./data/storage:${STORAGE_DIR}
entrypoint: ["sh", "-c"]
+15 -11
查看文件
@@ -5,21 +5,18 @@
## 0A. 如果官网 `hyb-web` 已在同机运行(推荐方案)
- 官网继续保持 `hyinda.com / www.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)`。
- 首次初始化时:
1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh`
2. 手工创建 `deploy/trialrun/.env`
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)
- SSH 用户:
- 常规部署:`[email protected]`
- 系统配置:`[email protected]`
- `shunli` 当前不可免密登录
## 0B. 历史部署基线(基础设施信息已脱敏)
- SSH 目标、部署账号和机器地址只保存在受控运维配置中,通过 `DEPLOY_SERVER` 显式传入。
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 已反代到 `127.0.0.1:3000`
- 部署目录:`/opt/hyb-oa-trialrun`
- 部署目录通过 `REMOTE_DIR` 显式传入。
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
- 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准
- 当前公网 `.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` 注入。
- `BUILD_SHA` / `VITE_BUILD_SHA` 由发布入口按已批准提交临时注入,不写入长期 `.env`;前者供后端运行时核对,后者只允许 12 位短 SHA 进入公网 bundle。
- 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。
- 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。
- 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。
@@ -60,10 +58,14 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
```bash
cd /path/to/hyb-oa
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 --build server
```
以上直接 Compose 命令仅用于首次初始化或受控应急;正式发布优先使用 `deploy/trialrun/deploy.sh`,由脚本从 clean、已进入 upstream 的提交自动注入两个构建标识。缺少任一标识时 Compose 必须拒绝解析。
可选:启用 MinIO(对象存储,F21):
```bash
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。
- 推荐直接执行 `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 脚本)
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` 清理远端历史证据。
3) 构建并重启:
```bash
@@ -310,7 +314,7 @@ docker logs -f hyb-oa-trialrun-server
## 6. R12-lite 试点切换 Runbook
### 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`。
- 登录系统后在“功能开关”页将对应开关切到 `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 requiredServices = ['server', 'worker', 'init'];
const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']);
const allowedOverrides = {
server: ['NODE_ENV'],
worker: ['NODE_ENV', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
init: ['NODE_ENV'],
server: ['NODE_ENV', 'BUILD_SHA'],
worker: ['NODE_ENV', 'BUILD_SHA', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
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) {
@@ -117,10 +128,15 @@ const envExampleKeys = extractEnvExampleKeys(envExampleContent);
const errors = [];
for (const key of configKeys) {
if (!envExampleKeys.has(key)) {
if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(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) {
const block = extractServiceBlock(composeContent, serviceName);
@@ -138,10 +154,22 @@ for (const serviceName of requiredServices) {
const illegalOverrides = environmentKeys.filter(
(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) {
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) {