fix deploy env bom handling

This commit is contained in:
2026-04-26 00:07:12 +08:00
parent c9a59f9edb
commit c4b9b8173f
6 changed files with 117 additions and 12 deletions

View File

@@ -0,0 +1,31 @@
# Jenkins 部署环境文件 BOM 修复
日期:`2026-04-25`
## 1. 问题
Jenkins 部署阶段执行固定目录内的 `start.sh` 时失败:
```text
/var/lib/jenkins/deploy/Genarrative/.env.local: line 1: VITE_LLM_BASE_URL=...: No such file or directory
```
根因是 `.env.local` 第一行包含 UTF-8 BOM。旧版 `start.sh` 直接 `source .env.local`BOM 会成为变量名前缀Bash 无法按赋值语句解析,进而把整行当作命令执行。日志末尾的 sudo 提示只是 hook 执行失败后的兜底提示,不是本次失败的真实根因。
## 2. 修复口径
1. 发布包构建脚本复制 `.env``.env.local` 到发布目录和 `web/` 目录后,统一移除 UTF-8 BOM 与 CRLF。
2. Jenkins 部署脚本在移动发布产物前后,再次净化发布目录和固定部署目录中的 `.env``.env.local`,兼容已经构建出来但尚未部署成功的旧发布包。
3. 新生成的 `start.sh` 不再直接 `source` 环境文件,而是按 `KEY=value` 子集解析、导出合法变量,并跳过空行、注释和不合法行。
4. `start.sh` 仍保留 `.env` 先于 `.env.local` 的加载顺序,后加载的 `.env.local` 可以覆盖默认配置。
## 3. 运行边界
1. 环境文件应保持 UTF-8 文本,允许 UTF-8 BOM 和 CRLF但部署脚本会在发布目录中消除它们。
2. 环境变量名必须符合 `[A-Za-z_][A-Za-z0-9_]*`
3. 值支持不加引号、双引号和单引号;复杂 shell 表达式不会执行,避免把环境文件变成脚本入口。
4. 业务密钥仍通过目标服务器环境变量或发布目录 `.env.local` 管理,不写入 Jenkinsfile。
## 4. 失败现场恢复
如果 Jenkins 已经生成了失败版本,可以在拉取本次脚本修复后直接重跑部署流水线。`scripts/jenkins-deploy-release.sh` 会在执行新版本 `start.sh` 前净化已有发布目录,因此不要求手工编辑服务器上的 `.env.local`

View File

@@ -99,7 +99,7 @@ scripts/jenkins-deploy-release.sh \
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF避免 `start.sh` 在 Bash 下把首行变量名误解析成命令
### 4.3 构建并部署

View File

@@ -43,6 +43,7 @@
- [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。
- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust``npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。

View File

@@ -116,9 +116,9 @@ npm run deploy:rust:remote
3. 使用 Vite 构建前端 release 到目标目录的 `web/`
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/`;复制后统一移除 UTF-8 BOM 与 CRLF避免目标服务器 Bash 加载环境文件失败
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*``/generated-*``/healthz` 反代到本包内的 `api-server`
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先加载发布目录根部的 `.env``.env.local`,再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先`KEY=value` 子集加载发布目录根部的 `.env``.env.local`兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
发布包结构:
@@ -160,10 +160,11 @@ cd build/<timestamp>
1. 构建脚本会把仓库根目录已有的 `.env``.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。
2. 如果仓库根目录不存在 `.env``.env.local`,脚本会打印跳过日志,但不会因此失败;此时 `start.sh` 仅使用构建时写入的默认值与运行时显式传入的环境变量。
3. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发
4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`在 schema 冲突时删除旧模块数据。
5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
6. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传
3. `start.sh` 只解析合法 `KEY=value` 环境行,支持不加引号、双引号和单引号;不执行复杂 shell 表达式,避免把环境文件变成脚本入口
4. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database`追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发
5. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据
6. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
7. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。
目标服务器最小要求:

View File

@@ -57,6 +57,19 @@ copy_required_file() {
cp "${source_path}" "${target_path}"
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
copy_optional_file() {
local source_path="$1"
local target_path_a="$2"
@@ -70,6 +83,8 @@ copy_optional_file() {
cp "${source_path}" "${target_path_a}"
cp "${source_path}" "${target_path_b}"
normalize_env_file "${target_path_a}"
normalize_env_file "${target_path_b}"
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a}${target_path_b}"
}
@@ -426,17 +441,47 @@ cd "${SCRIPT_DIR}"
load_env_file() {
local env_file="$1"
local line=""
local line_number=0
local key=""
local value=""
local utf8_bom=$'\xef\xbb\xbf'
if [[ ! -f "${env_file}" ]]; then
return
fi
echo "[start] 加载环境文件: ${env_file}"
set -a
# 发布包内环境文件由当前构建脚本生成,允许在启动时作为默认环境源加载
# shellcheck disable=SC1090
source "${env_file}"
set +a
# 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行
while IFS= read -r line || [[ -n "${line}" ]]; do
line_number=$((line_number + 1))
if [[ "${line_number}" -eq 1 ]]; then
line="${line#"${utf8_bom}"}"
fi
line="${line%$'\r'}"
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
continue
fi
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2
continue
fi
key="${BASH_REMATCH[2]}"
value="${BASH_REMATCH[3]}"
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
value="${value:1:${#value}-2}"
value="${value//\\\"/\"}"
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
printf -v "${key}" '%s' "${value}"
export "${key}"
done <"${env_file}"
}
usage() {
@@ -655,6 +700,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
## 环境变量
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`

View File

@@ -32,6 +32,28 @@ require_argument() {
fi
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 兼容由 Windows 编辑器或 Jenkins 参数落盘产生的 BOM/CRLF避免 start.sh 加载时报命令不存在。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
normalize_release_env_files() {
local release_dir="$1"
normalize_env_file "${release_dir}/.env"
normalize_env_file "${release_dir}/.env.local"
normalize_env_file "${release_dir}/web/.env"
normalize_env_file "${release_dir}/web/.env.local"
}
SOURCE_DIR=""
DEPLOY_DIR=""
CLEAR_DATABASE="0"
@@ -125,6 +147,8 @@ if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
exit 1
fi
normalize_release_env_files "${SOURCE_DIR}"
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
run_hook "${DEPLOY_DIR}" "stop.sh"
@@ -154,6 +178,8 @@ if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
chmod +x "${DEPLOY_DIR}/stop.sh"
fi
normalize_release_env_files "${DEPLOY_DIR}"
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
echo "[jenkins-deploy] 以清库模式启动新版本"