完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)
这个提交包含在:
+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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export class SourceProvenanceError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.name = "SourceProvenanceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_UNPUBLISHED_REASONS = new Set([
|
||||
"offline-disaster-recovery",
|
||||
"remote-outage-approved",
|
||||
"controlled-local-recovery",
|
||||
]);
|
||||
|
||||
function runGit(rootDir, args) {
|
||||
return spawnSync("git", args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function gitText(rootDir, args, failureCode) {
|
||||
const result = runGit(rootDir, args);
|
||||
if (result.status !== 0) {
|
||||
throw new SourceProvenanceError(failureCode);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
export function collectSourceProvenance({ rootDir, allowUnpublished = false }) {
|
||||
const inside = gitText(rootDir, ["rev-parse", "--is-inside-work-tree"], "not_a_git_worktree");
|
||||
if (inside !== "true") {
|
||||
throw new SourceProvenanceError("not_a_git_worktree");
|
||||
}
|
||||
|
||||
const dirty = gitText(
|
||||
rootDir,
|
||||
["status", "--porcelain=v1", "--untracked-files=all"],
|
||||
"git_status_failed",
|
||||
);
|
||||
if (dirty) {
|
||||
throw new SourceProvenanceError("dirty_worktree");
|
||||
}
|
||||
|
||||
const commit = gitText(rootDir, ["rev-parse", "--verify", "HEAD"], "commit_unavailable");
|
||||
const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable") || "detached";
|
||||
const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||
const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null;
|
||||
if (!upstream && !allowUnpublished) {
|
||||
throw new SourceProvenanceError("upstream_missing");
|
||||
}
|
||||
|
||||
const published = Boolean(upstream) && runGit(rootDir, ["merge-base", "--is-ancestor", commit, upstream]).status === 0;
|
||||
if (!published && !allowUnpublished) {
|
||||
throw new SourceProvenanceError("commit_not_in_upstream");
|
||||
}
|
||||
|
||||
return {
|
||||
status: "PASS",
|
||||
commit,
|
||||
shortCommit: commit.slice(0, 12),
|
||||
branch,
|
||||
upstream,
|
||||
published,
|
||||
overrideUsed: !published,
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshUpstreamRef({ rootDir, allowFailure = false }) {
|
||||
const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable");
|
||||
const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||
const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null;
|
||||
if (!upstream) {
|
||||
if (allowFailure) {
|
||||
return { refreshed: false, upstream: null, remote: null, code: "upstream_missing" };
|
||||
}
|
||||
throw new SourceProvenanceError("upstream_missing");
|
||||
}
|
||||
|
||||
const remoteResult = runGit(rootDir, ["config", "--get", `branch.${branch}.remote`]);
|
||||
const remote = remoteResult.status === 0 ? remoteResult.stdout.trim() : null;
|
||||
if (!remote || remote === ".") {
|
||||
if (allowFailure) {
|
||||
return { refreshed: false, upstream, remote: null, code: "upstream_remote_missing" };
|
||||
}
|
||||
throw new SourceProvenanceError("upstream_remote_missing");
|
||||
}
|
||||
|
||||
const fetchResult = runGit(rootDir, ["fetch", "--quiet", "--prune", remote]);
|
||||
if (fetchResult.status !== 0) {
|
||||
if (allowFailure) {
|
||||
return { refreshed: false, upstream, remote, code: "upstream_refresh_failed" };
|
||||
}
|
||||
throw new SourceProvenanceError("upstream_refresh_failed");
|
||||
}
|
||||
return { refreshed: true, upstream, remote, code: null };
|
||||
}
|
||||
|
||||
function parseRootArg(argv) {
|
||||
const item = argv.find((value) => value.startsWith("--root="));
|
||||
if (item) {
|
||||
return path.resolve(item.slice("--root=".length));
|
||||
}
|
||||
return path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const rootDir = parseRootArg(process.argv.slice(2));
|
||||
const allowUnpublished = process.env.DEPLOY_ALLOW_UNPUBLISHED_COMMIT === "1";
|
||||
const overrideReason = String(process.env.DEPLOY_UNPUBLISHED_REASON ?? "").trim();
|
||||
if (allowUnpublished && !ALLOWED_UNPUBLISHED_REASONS.has(overrideReason)) {
|
||||
throw new SourceProvenanceError("unpublished_override_reason_invalid");
|
||||
}
|
||||
|
||||
collectSourceProvenance({ rootDir, allowUnpublished: true });
|
||||
const refresh = refreshUpstreamRef({ rootDir, allowFailure: allowUnpublished });
|
||||
const result = collectSourceProvenance({ rootDir, allowUnpublished });
|
||||
console.log(JSON.stringify({
|
||||
...result,
|
||||
remoteVerified: refresh.refreshed,
|
||||
upstreamRefreshCode: refresh.code,
|
||||
overrideRequested: allowUnpublished,
|
||||
overrideReason: allowUnpublished ? overrideReason : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
const code = error instanceof SourceProvenanceError ? error.code : "unexpected_error";
|
||||
console.error(JSON.stringify({ status: "FAIL", code }));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
@@ -14,12 +14,12 @@
|
||||
- [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile`
|
||||
- [ ] 已确认 `oa.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"
|
||||
|
||||
@@ -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` 记录。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户