将预览密钥内置到API镜像
Project CI / Repository checks (push) Successful in 4m32s
Project CI / Frontend tests (push) Successful in 4m34s
Project CI / Backend tests (push) Successful in 5m18s
Project CI / Native shell tests (push) Failing after 1m37s

固定Jenkins预览密钥宿主路径并收紧文件校验

通过BuildKit secret向API和工作进程镜像安装密钥文件

增加摘要缓存门禁和普通无密钥构建兼容验证

同步预览部署文档、安全边界与排障记忆
This commit is contained in:
2026-08-22 15:45:40 +08:00
parent 27de28a487
commit d631253c34
8 changed files with 154 additions and 3 deletions
+9 -1
View File
@@ -58,6 +58,14 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.7.0` 依赖链继续兼容 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。Web builder 显式安装并校验 npm `10.9.7`,再按唯一根 workspace lock 执行一次 `npm ci`,不依赖 Node 基础镜像隐含的 npm 版本。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
### Jenkins 预览 secrets 镜像边界
Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.secrets.local` 读取 secrets。目录由 Jenkins 运行账号所有且权限为 `0700`,文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。不要把真实值写入本 README、仓库示例或 Jenkins 参数。
该文件不复制到源码 checkout 和 Docker build context,而是以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它安装到 API 运行镜像的 `/srv/genarrative/.env.secrets.local`owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得该 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于该内置文件,可按预览实例覆盖其中的值。
修改宿主固定文件后必须重新构建并替换 API 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。
### Gitea CI 预构建 Job 镜像
`.gitea/workflows/project-ci.yml` 的四个 job 统一使用 `deploy/container/gitea-ci-job.Dockerfile` 构建的 `genarrative-ci` 环境。镜像固定 Ubuntu job base digest `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614` 和 Rust stage digest `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`Node `22.23.1` 发行包在解压前执行 SHA-256 校验,Google Linux 主签名指纹固定为 `EB4C1BFD4F042F6DDDCCEC917721F63BD38B4796`Chrome 固定为 `150.0.7871.181-1`。镜像预装 Rust `1.96.0``rustfmt`、Chrome、`bwrap``rg``ffmpeg``clang/lld` 和 Tauri / 后端系统依赖,并设置 `RUSTUP_AUTO_INSTALL=0`;仓库工具链变更时必须先重建镜像,不允许 job 现场下载补齐。
@@ -245,5 +253,5 @@ npm run container:logs -- otelcol
- 不改生产 systemd 单元。
- 不改 Jenkins 发布主流程。
- 不要求真实 HTTPS 证书。
- 不把真实 `.env``.env.local``.env.secrets.local``deploy/container/api-server.env` 放入 Docker build context。
- 不把真实 `.env``.env.local``.env.secrets.local``deploy/container/api-server.env` 放入 Docker build contextJenkins 预览只使用上述 BuildKit secret 边界将固定文件内置到 `api-runtime`
- 不在容器镜像里内置 SpacetimeDB 数据或 token。
+11
View File
@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM rust:1.93-bookworm AS rust-builder
WORKDIR /workspace
@@ -21,6 +23,15 @@ COPY --from=rust-builder /tmp/api-server /usr/local/bin/api-server
RUN mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox && \
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=
RUN --mount=type=secret,id=genarrative_preview_secrets,required=false \
if [ -n "${GENARRATIVE_PREVIEW_SECRETS_SHA256}" ]; then \
test -f /run/secrets/genarrative_preview_secrets; \
test "$(sha256sum /run/secrets/genarrative_preview_secrets | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_SECRETS_SHA256}"; \
install -o genarrative -g genarrative -m 0400 \
/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local; \
fi
USER genarrative
EXPOSE 8082
@@ -14325,3 +14325,9 @@
- 原生边界:根 H5 与 Desktop manifest 继续禁止 Tauri JS guestAGC workspace 可以声明;统一 lock 出现 AGC guest 是合法聚合结果。Expo 沿用默认 npm monorepo 支持。AGC Cubone bundle、TypeScript、Tauri CLI 与 Windows Codex sidecar 都必须兼容根提升位置,不得依赖子 App 固定 `node_modules` 层级。
- 锁与平台:删除 AGC 和 Spine 子 lock;统一根 lock 必须保留 optional、bundled 和跨平台二进制节点。Linux 干净安装不能替代 Windows AGC sidecar、Android Expo/EAS 或可用 macOS/iOS runner 的平台构建证据。
- 权威方案:`docs/technical/【技术方案】npm-workspaces统一依赖边界-2026-08-21.md`
## 2026-08-22 Jenkins 预览只向 API 运行镜像内置固定 secrets
- 决策:预览 secrets 权威源固定为 Jenkins 宿主 `/data/jenkins/preview-secrets/.env.secrets.local`;目录 / 文件由 Jenkins 运行账号所有且权限分别为 `0700` / `0600`,缺失、链接、非普通文件、owner 异常或权限过宽时构建失败关闭。
- 构建边界:只通过 BuildKit secret mount 把文件提供给 `api-runtime` stage,并安装为 `/srv/genarrative/.env.secrets.local` (`genarrative:genarrative`, `0400`)。文件不进 Git、build context、日志或 artifact,不进入 Web / Nginx、SpacetimeDB 或其它镜像。容器显式运行 env 优先覆盖内置值。
- 更新与分发:固定源文件更新后必须重建并替换镜像,只重启容器无效。镜像可读者必然可提取内置 secrets,因此只允许留在当前受信任内网 Docker 主机,禁止 push、`docker save` 或作为 artifact 导出到跨信任边界的 registry、主机或存储。
@@ -4824,3 +4824,10 @@
- 原因:两个 Cargo workspace 拥有独立 target 和锁文件,AGC 又以 path dependency 复用若干 server-rs crate;更主要的是全量 / 分组 `cargo test` 继承增量编译,每组 crate / feature / profile hash 都可以留下新会话,Cargo 不会按仓库期望自动收缩这些历史目录。
- 处理:保留两个 workspace 的产品 / 发布边界;两边 `[profile.test]` 关闭 incremental 并固定 `debug=1`AGC dev profile 与 server-rs 对齐调试信息级别。日常用 `npm run audit:rust-build-cache` 只读核对;需要回收时先停止 Cargo / rustc,再显式运行 `npm run clean:rust-incremental -- --apply`,只删两个固定增量目录。
- 验证:清理前后各跑一次只读审计并核对磁盘可用空间;分别运行 server-rs 与 AGC 定向 `cargo test`,确认 test profile 不再生成持久 `debug/incremental` 堆积。共享 `CARGO_TARGET_DIR` 必须另做并发启动基准,不得为节省磁盘直接改变生产产物路径。
## BuildKit secret 不等于镜像内 secrets 不可提取(2026-08-22
- 现象:构建时使用 BuildKit secret mount,日志和普通 build context 都没有出现明文,于是误以为最终镜像也能不可提取地保存 secrets,随后将镜像 push 或导出给不同信任域。
- 原因:BuildKit secret mount 只避免秘密作为 `ARG` / `COPY` 进入构建上下文和中间指令;一旦 Dockerfile 把 mount 的内容安装到最终 rootfs,任何能读取、保存或运行该镜像的主体都可以提取它。
- 处理:预览固定 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将其安装到 `api-runtime:/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
- 更新与验证:源文件变更不会改动已存镜像,必须重建并替换容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。
@@ -47,7 +47,15 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。
每个预览实例使用独立 SpacetimeDB 空库,不继承生产账号和短信凭据。Jenkins 在实例私有 `api-server.env` 中开启预览专用认证:未注册的中国大陆手机号首次使用 6 到 128 位密码时自动创建预览账号;短信入口使用 `mock` provider 与固定预览验证码 `123456`,不向真实手机发送短信。该设置不写入公共 env 示例生产配置或镜像层;实例重建会重建独立数据库,原预览账号不保留。
每个预览实例使用独立 SpacetimeDB 空库,不继承生产账号据。Jenkins 在实例私有 `api-server.env` 中开启预览专用认证:未注册的中国大陆手机号首次使用 6 到 128 位密码时自动创建预览账号;短信入口强制使用 `mock` provider 与固定预览验证码 `123456`,不调用镜像内置的真实短信凭据。该预览认证覆盖不写入公共 env 示例生产配置;实例重建会重建独立数据库,原预览账号不保留。
## 预览 secrets 内置
Jenkins 节点上的预览 secrets 权威来源固定为 `/data/jenkins/preview-secrets/.env.secrets.local`。该文件不进 Git、Docker build context、构建日志或 artifact;构建时只通过 BuildKit `secret` mount 临时提供给 `api-runtime` stage,并在该运行镜像中安装为 `/srv/genarrative/.env.secrets.local`,权限固定为 `0400``nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含该文件。
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,源文件权限为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。源文件变更后必须重新构建并替换预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.secrets.local`,用于按实例覆盖非通用值。
这种方案只隐藏构建传输过程,不能让内置后的 secrets 对镜像持有者保密:能读取、保存或运行 `api-runtime` 镜像的人可以提取该文件。因此该镜像只能留在当前受信任内网 Docker 主机,禁止 push 到公共或跨信任边界的 registry,也禁止通过 `docker save`/构建 artifact 导出传播。需要跨边界分发时必须改用不含 secrets 的镜像与运行时密钥注入。
## Jenkins 参数与产物
@@ -110,6 +118,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer``Job/Read``Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer``Job/Configure``Job/Delete`
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
- 预览 secrets 只从固定宿主路径读取,构建前校验 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写 secrets 路径、BuildKit secret ID 或镜像内目标路径。
- Jenkins POST 支持动态 CrumbAPI Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
@@ -120,5 +129,6 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
- 后端:输入校验、登录会话、Origin、Crumb、Jenkins `401/403/404/5xx`、queue 到 build 状态机、artifact schema、卸载所有权和幂等测试。
- 前端:登录、分支与可选 commit、自动刷新、排队/构建/成功/失败状态、内网链接、卸载确认和刷新恢复测试。
- Jenkins:两个分支依次发布后在不同端口并存;同一分支换 commit 优先复用端口;非分支 commit 被拒绝;卸载只删除目标实例并释放端口。
- 预览认证:新手机号可以首次密码登录并重复使用同一密码;错误密码被拒绝;获取验证码后使用 `123456` 可完成登录;容器不包含生产短信凭据。
- 预览认证:新手机号可以首次密码登录并重复使用同一密码;错误密码被拒绝;获取验证码后使用 `123456` 可完成登录;短信入口保持 `mock` 且不调用内置的真实短信凭据。
- secrets:构建 transcript、context 和 artifact 零出现原文;只有 `api-runtime` 包含 `0400` 的目标文件,`nginx-runtime` 与其它镜像均不包含;修改固定源文件后旧镜像不变、重建新镜像后摘要更新;显式运行 env 可覆盖内置值。
- 通用:`npm run check:encoding`、相关 typecheck/build/test、Rust 定向测试和 `git diff --check`
+1
View File
@@ -14,6 +14,7 @@ pipeline {
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments'
GENARRATIVE_PREVIEW_SECRETS_FILE = '/data/jenkins/preview-secrets/.env.secrets.local'
GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'
}
+69
View File
@@ -5,6 +5,10 @@ const jenkinsfile = readFileSync(
'utf8',
);
const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8');
const apiServerDockerfile = readFileSync(
'deploy/container/api-server.Dockerfile',
'utf8',
);
const resultWriter = readFileSync(
'scripts/preview-deployment-status.mjs',
'utf8',
@@ -125,6 +129,67 @@ assertIncludes(
'restart: on-failure',
'预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。',
);
assertIncludes(
deployer,
'GENARRATIVE_PREVIEW_SECRETS_SHA256',
'预览构建必须把固定 secrets 文件摘要作为镜像缓存与完整性校验参数。',
);
assertIncludes(
jenkinsfile,
"GENARRATIVE_PREVIEW_SECRETS_FILE = '/data/jenkins/preview-secrets/.env.secrets.local'",
'Jenkins 必须从受保护的固定宿主路径读取预览 secrets。',
);
assertIncludes(
deployer,
'[[ "${secrets_mode}" == "600" ]]',
'预览构建必须拒绝权限过宽的 secrets 文件。',
);
assertIncludes(
deployer,
'[[ "${secrets_owner}" == "${EUID}" ]]',
'预览构建必须校验 secrets 文件归 Jenkins 执行用户所有。',
);
assertIncludes(
deployer,
'genarrative_preview_secrets',
'预览 API 与外部生成 worker 构建必须使用固定名称的 BuildKit secret。',
);
assertCount(
deployer,
'target: genarrative_preview_secrets',
2,
'预览 secrets 必须且只能提供给 API 和外部生成 worker 两个构建。',
);
assertIncludes(
apiServerDockerfile,
'ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=',
'API 镜像必须允许普通构建不提供预览 secrets 摘要。',
);
assertIncludes(
apiServerDockerfile,
'RUN --mount=type=secret,id=genarrative_preview_secrets,required=false',
'API 镜像必须通过可选 BuildKit secret 接收预览 secrets 文件。',
);
assertIncludes(
apiServerDockerfile,
'test -f /run/secrets/genarrative_preview_secrets;',
'提供预览 secrets 摘要时必须要求 BuildKit secret 存在。',
);
assertIncludes(
apiServerDockerfile,
'sha256sum /run/secrets/genarrative_preview_secrets',
'API 镜像必须在安装前校验预览 secrets 文件摘要。',
);
assertIncludes(
apiServerDockerfile,
'install -o genarrative -g genarrative -m 0400',
'预览 secrets 文件必须只允许 API 运行用户读取。',
);
assertIncludes(
apiServerDockerfile,
'/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local;',
'预览 secrets 文件必须安装到 API 启动时读取的固定路径。',
);
assertIncludes(
deployer,
'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true',
@@ -256,3 +321,7 @@ function assertIncludes(content, expected, message) {
function assertExcludes(content, expected, message) {
if (content.includes(expected)) failures.push(message);
}
function assertCount(content, expected, count, message) {
if (content.split(expected).length - 1 !== count) failures.push(message);
}
+39
View File
@@ -10,6 +10,7 @@ SOURCE_DIR="${SOURCE_DIR:-${WORKSPACE:-$(pwd)}/source}"
RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}"
DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}"
STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}"
PREVIEW_SECRETS_FILE="${GENARRATIVE_PREVIEW_SECRETS_FILE:-/data/jenkins/preview-secrets/.env.secrets.local}"
WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}"
LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}"
@@ -18,6 +19,7 @@ STATE_FILE=""
PROJECT_NAME=""
SCRIPT_ROOT=""
SCRIPT_FAILED=1
PREVIEW_SECRETS_SHA256=""
fail() {
echo "[preview-deployer] $*" >&2
@@ -244,6 +246,27 @@ allocate_port() {
fail "端口范围 ${start}-${end} 已无可用端口。"
}
validate_preview_secrets_file() {
local secrets_dir secrets_mode secrets_owner canonical_secrets canonical_source
[[ "${PREVIEW_SECRETS_FILE}" == /* ]] || fail "预览 secrets 文件必须使用绝对路径。"
[[ -f "${PREVIEW_SECRETS_FILE}" && ! -L "${PREVIEW_SECRETS_FILE}" && -r "${PREVIEW_SECRETS_FILE}" ]] || \
fail "预览 secrets 文件必须是 Jenkins 可读的非符号链接普通文件: ${PREVIEW_SECRETS_FILE}"
secrets_dir="$(dirname "${PREVIEW_SECRETS_FILE}")"
[[ -d "${secrets_dir}" && ! -L "${secrets_dir}" ]] || \
fail "预览 secrets 目录必须是非符号链接目录: ${secrets_dir}"
secrets_mode="$(stat -c '%a' "${PREVIEW_SECRETS_FILE}")"
[[ "${secrets_mode}" == "600" ]] || fail "预览 secrets 文件权限必须是 0600: ${PREVIEW_SECRETS_FILE}"
secrets_owner="$(stat -c '%u' "${PREVIEW_SECRETS_FILE}")"
[[ "${secrets_owner}" == "${EUID}" ]] || fail "预览 secrets 文件必须归当前 Jenkins 执行用户所有。"
canonical_secrets="$(realpath -e "${PREVIEW_SECRETS_FILE}")"
canonical_source="$(realpath -e "${SOURCE_DIR}")"
[[ "${canonical_secrets}" != "${canonical_source}"/* ]] || \
fail "预览 secrets 文件不能位于目标分支源码上下文内。"
PREVIEW_SECRETS_SHA256="$(sha256sum "${PREVIEW_SECRETS_FILE}")"
PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256%% *}"
[[ "${PREVIEW_SECRETS_SHA256}" =~ ^[0-9a-f]{64}$ ]] || fail "无法计算预览 secrets 文件摘要。"
}
remove_project_resources() {
local ids=()
local images=()
@@ -273,6 +296,8 @@ compose() {
COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \
GENARRATIVE_PREVIEW_SOURCE_DIR="${SOURCE_DIR}" \
GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \
GENARRATIVE_PREVIEW_SECRETS_FILE="${PREVIEW_SECRETS_FILE}" \
GENARRATIVE_PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256}" \
GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \
GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \
GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \
@@ -292,10 +317,20 @@ services:
build:
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
args:
GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256}
secrets:
- source: preview_runtime_env
target: genarrative_preview_secrets
external-generation-worker:
build:
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
args:
GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256}
secrets:
- source: preview_runtime_env
target: genarrative_preview_secrets
restart: on-failure
nginx:
build:
@@ -306,6 +341,9 @@ services:
ports: !reset []
otelcol:
ports: !reset []
secrets:
preview_runtime_env:
file: ${GENARRATIVE_PREVIEW_SECRETS_FILE}
YAML
chmod 0600 "${override_file}"
}
@@ -394,6 +432,7 @@ deploy() {
[[ "${source_commit}" == "${requested_commit}" ]] || fail "源码 checkout 与 COMMIT_HASH 不一致。"
fi
validate_preview_secrets_file
saved_web="$(state_value webPort)"
WEB_PORT="${saved_web:-8400}"
SPACETIME_PORT=""