修复(08-08-production-deployment-execution): 校验候选 Compose 构建标识
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-08 20:44:30 +08:00
父节点 83af7544c4
当前提交 8b2ed5bb16
共修改 6 个文件,包含 91 行新增和 16 行删除
+38 -1
查看文件
@@ -1554,7 +1554,11 @@ DEPLOY_SERVER=<approved-ssh-target> \
- Remote read-only helper: `deploy/trialrun/production-preflight-remote.sh`. - Remote read-only helper: `deploy/trialrun/production-preflight-remote.sh`.
- Required process inputs: `EXPECTED_PRODUCTION_ORIGIN`, - Required process inputs: `EXPECTED_PRODUCTION_ORIGIN`,
`EXPECTED_PRODUCTION_DB_NAME_SHA256`, `EXPECTED_PENDING_MIGRATIONS`, and the `EXPECTED_PRODUCTION_DB_NAME_SHA256`, `EXPECTED_PENDING_MIGRATIONS`, and the
exact deployment target/directory/Compose identity values. exact deployment target/directory/Compose identity values. The strict wrapper
must also pass its provenance-verified `CANDIDATE_COMMIT`; preflight must not
fall back to `git rev-parse HEAD`.
- Candidate Compose interpolation: `BUILD_SHA=<full candidate commit>` and
`VITE_BUILD_SHA=<first 12 hex characters of BUILD_SHA>`.
### 3. Contracts ### 3. Contracts
@@ -1576,6 +1580,14 @@ DEPLOY_SERVER=<approved-ssh-target> \
candidate manifest. Pending must exactly equal the explicit allowlist; candidate manifest. Pending must exactly equal the explicit allowlist;
pending IDs reported by status must belong to the candidate manifest; pending IDs reported by status must belong to the candidate manifest;
failed, rolled-back, unfinished, diverged or unknown history fails closed. failed, rolled-back, unfinished, diverged or unknown history fails closed.
- `docker compose config` validates the candidate configuration that would be
deployed, not the currently running container configuration. The local
orchestrator derives both Compose build variables only from the
provenance-verified candidate: the full commit becomes `BUILD_SHA` and its
first 12 characters become `VITE_BUILD_SHA`. The remote helper requires both,
validates their hexadecimal lengths and asserts the prefix relationship.
Never use `--no-interpolate`, which would bypass required `${VAR:?...}`
constraints instead of checking the candidate configuration.
- The preflight helper is read-only: it does not run `migrate dev`, reset, - The preflight helper is read-only: it does not run `migrate dev`, reset,
`db push`, `migrate resolve`, seed, DDL or business writes. `deploy.sh` is `db push`, `migrate resolve`, seed, DDL or business writes. `deploy.sh` is
reached only after target and pre-status gates pass. reached only after target and pre-status gates pass.
@@ -1609,6 +1621,12 @@ DEPLOY_SERVER=<approved-ssh-target> \
- Missing or invalid expected target input -> stop before SSH mutation. - Missing or invalid expected target input -> stop before SSH mutation.
- Target hash, runtime mode, cookie security or DB identity mismatch -> stop - Target hash, runtime mode, cookie security or DB identity mismatch -> stop
before `deploy.sh`. before `deploy.sh`.
- Missing/invalid provenance candidate -> `CANDIDATE_COMMIT_MISSING` or
`CANDIDATE_COMMIT_INVALID`; do not infer a candidate from local HEAD.
- Invalid full/short build SHA or a short SHA that is not the first 12
characters of the full SHA -> `PROD_TARGET_BUILD_SHA_INVALID`,
`PROD_TARGET_VITE_BUILD_SHA_INVALID` or `PROD_TARGET_BUILD_SHA_MISMATCH`;
stop before Compose config and Prisma status.
- Status output missing/unreadable, failed/rolled-back/diverged history or an - Status output missing/unreadable, failed/rolled-back/diverged history or an
unknown pending migration -> `PAUSED`; do not reset or resolve in place. unknown pending migration -> `PAUSED`; do not reset or resolve in place.
- Non-zero remote exit with a safe `code=...`, with or without a Prisma marker - Non-zero remote exit with a safe `code=...`, with or without a Prisma marker
@@ -1627,10 +1645,16 @@ DEPLOY_SERVER=<approved-ssh-target> \
- Good: the remote helper exits non-zero with a stable database-identity code; - Good: the remote helper exits non-zero with a stable database-identity code;
the wrapper writes the stable evidence summary, returns that exact code and the wrapper writes the stable evidence summary, returns that exact code and
stops before migration or deployment. stops before migration or deployment.
- Good: the verified candidate commit is injected as the full build SHA and
its 12-character prefix, so Compose validates exactly the configuration that
a later approved deployment would activate.
- Base: the remote helper exits zero with a readable Prisma marker and the - Base: the remote helper exits zero with a readable Prisma marker and the
wrapper continues into migration reconciliation. wrapper continues into migration reconciliation.
- Bad: check for a missing marker before checking the remote exit and replace - Bad: check for a missing marker before checking the remote exit and replace
the real remote safety code with `PRISMA_STATUS_OUTPUT_MISSING`. the real remote safety code with `PRISMA_STATUS_OUTPUT_MISSING`.
- Bad: omit build SHA interpolation, use a seven-character Git abbreviation,
re-read HEAD inside preflight, or pass `--no-interpolate` merely to make
Compose config return zero.
### 6. Tests Required ### 6. Tests Required
@@ -1645,6 +1669,10 @@ DEPLOY_SERVER=<approved-ssh-target> \
and missing marker; non-zero with a safe code and present marker; non-zero and missing marker; non-zero with a safe code and present marker; non-zero
without a safe code; zero with a missing marker. Also assert the stable without a safe code; zero with a missing marker. Also assert the stable
summary and report reference are emitted before either failure check. summary and report reference are emitted before either failure check.
- Assert the SSH command receives the provenance candidate as `BUILD_SHA` and
its exact 12-character prefix as `VITE_BUILD_SHA`; reject malformed and
individually valid-but-mismatched pairs. Static checks must prove there is no
local-HEAD candidate fallback.
### 7. Wrong vs Correct ### 7. Wrong vs Correct
@@ -1662,6 +1690,15 @@ write stable evidence -> inspect remote non-zero exit and propagate/fallback
-> only after remote success require the Prisma marker -> only after remote success require the Prisma marker
``` ```
```text
# Wrong: validate a non-interpolated or unrelated working-tree configuration.
git rev-parse HEAD -> docker compose config --no-interpolate
# Correct: validate the exact published candidate configuration.
provenance-verified commit -> BUILD_SHA + first 12 chars as VITE_BUILD_SHA
-> interpolated docker compose config
```
## Scenario: Environment Recovery Classification And Secret Custody ## Scenario: Environment Recovery Classification And Secret Custody
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -136,3 +136,18 @@
- 聚焦回归覆盖四种组合:非零/有安全码/marker 缺失,非零/有安全码/marker 存在,非零/无可解析码/marker 缺失,以及退出 0/marker 缺失;`deployment-safety-contract.test.js` 为 19/19 PASS。 - 聚焦回归覆盖四种组合:非零/有安全码/marker 缺失,非零/有安全码/marker 存在,非零/无可解析码/marker 缺失,以及退出 0/marker 缺失;`deployment-safety-contract.test.js` 为 19/19 PASS。
- 本地 Node/shell syntax、env contract、task validate、process contract、docs audit、Trellis governance、sensitive scan 与 `git diff --check` 均通过;docs audit 仅保留仓库既有的未注册文档 warning。 - 本地 Node/shell syntax、env contract、task validate、process contract、docs audit、Trellis governance、sensitive scan 与 `git diff --check` 均通过;docs audit 仅保留仓库既有的未注册文档 warning。
- 本节修复阶段未连接生产、未运行 target/status、未执行 rsync、migration、应用切换或业务 API;生产 schema 写入与业务写入仍为 0。候选提交进入上游且 provenance 重新通过后,才允许重跑只读 pre-status;取得真实远端错误码后必须立即停止并复核原因。 - 本节修复阶段未连接生产、未运行 target/status、未执行 rsync、migration、应用切换或业务 API;生产 schema 写入与业务写入仍为 0。候选提交进入上游且 provenance 重新通过后,才允许重跑只读 pre-status;取得真实远端错误码后必须立即停止并复核原因。
## Read-only Candidate Compose Attempt Evidence (2026-08-08)
- 错误优先级修复提交 `83af754` 已进入 `origin/main`;执行前 provenance 为 `published=true`、`remoteVerified=true`,工作区 clean。
- 使用 `PRODUCTION_READ_ONLY_PREFLIGHT=1` 重跑:精确身份组通过,既有部署聚合体检仍为 `PASS=13 FAIL=0`;production preflight 在 Compose 配置校验处以真实稳定码 `PROD_TARGET_COMPOSE_CONFIG_INVALID` 停止,外层 `DEPLOY_EXISTING_FAILED` 只表示受控 wrapper 已停止。
- 源码核对确认 Compose 文件以 `${BUILD_SHA:?...}` / `${VITE_BUILD_SHA:?...}` 强制要求构建标识;正式 `deploy.sh` 从已验证 provenance 注入完整 commit 与 12 位短 commit,而只读 preflight 未注入两值,因此失败属于 preflight 候选配置输入缺口,不是生产配置漂移。
- 本次未生成 Prisma status marker 或脱敏 status 文件,未完成 `.env` / `current_database()` / runtime `DATABASE_URL` 三方一致性、pending 对账或 `terminated` count;rollback window 仍为 `UNKNOWN`。
- 失败后立即停止,未进入 `deploy.sh`、rsync、`migrate deploy`、应用切换、refresh 或业务 API;生产 schema 写入与业务写入均为 0。
## Candidate Compose Contract Repair Evidence (2026-08-08)
- `deploy-existing.sh` 仍是候选来源唯一入口:它先运行 `check-source-provenance.mjs`,只在 `published=true` / `remoteVerified=true` 后把 `source_commit` 传为 `CANDIDATE_COMMIT`。`production-preflight.mjs` 已移除 `git rev-parse HEAD` fallback,候选缺失直接 fail-closed。
- preflight 仅从该候选派生 `BUILD_SHA=<完整 commit>` 与 `VITE_BUILD_SHA=<前 12 位>`,并传给远端 Compose;不使用 7 位 Git 默认短 hash。远端 helper 在 `docker compose config` 前校验完整/短 SHA 格式以及 `VITE_BUILD_SHA === BUILD_SHA.slice(0, 12)`。
- 此处 `docker compose config` 验证的是候选发布后将生效的配置,不代表当前运行容器已经使用该 build SHA;运行态仍须由后续只读 runtime/SHA 检查独立证明。禁止用 `--no-interpolate` 绕过 Compose 的必填变量合同。
- 聚焦测试保持 19/19 PASS,并新增候选完整/12 位注入、7 位短 SHA 拒绝、合法但不匹配 SHA 拒绝、禁止 HEAD fallback 的覆盖;Node/shell syntax 与 env contract PASS。
@@ -15,7 +15,8 @@ pass() {
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME \ for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME \
POSTGRES_DATA_DIR STORAGE_HOST_DIR EXPECTED_PRODUCTION_ORIGIN_SHA256 \ POSTGRES_DATA_DIR STORAGE_HOST_DIR EXPECTED_PRODUCTION_ORIGIN_SHA256 \
EXPECTED_PRODUCTION_DB_NAME_SHA256 CANDIDATE_MIGRATIONS_CSV \ EXPECTED_PRODUCTION_DB_NAME_SHA256 BUILD_SHA VITE_BUILD_SHA \
CANDIDATE_MIGRATIONS_CSV \
EXPECTED_PENDING_MIGRATIONS_CSV; do EXPECTED_PENDING_MIGRATIONS_CSV; do
[[ -n "${!key:-}" || "$key" == "EXPECTED_PENDING_MIGRATIONS_CSV" ]] \ [[ -n "${!key:-}" || "$key" == "EXPECTED_PENDING_MIGRATIONS_CSV" ]] \
|| fail "${key}_MISSING" || fail "${key}_MISSING"
@@ -25,6 +26,12 @@ done
|| fail "PROD_TARGET_ORIGIN_HASH_INVALID" || fail "PROD_TARGET_ORIGIN_HASH_INVALID"
[[ "$EXPECTED_PRODUCTION_DB_NAME_SHA256" =~ ^[0-9a-f]{64}$ ]] \ [[ "$EXPECTED_PRODUCTION_DB_NAME_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|| fail "PROD_TARGET_DB_HASH_INVALID" || fail "PROD_TARGET_DB_HASH_INVALID"
[[ "$BUILD_SHA" =~ ^[0-9a-f]{40,64}$ ]] \
|| fail "PROD_TARGET_BUILD_SHA_INVALID"
[[ "$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$ ]] \
|| fail "PROD_TARGET_VITE_BUILD_SHA_INVALID"
[[ "${BUILD_SHA:0:12}" == "$VITE_BUILD_SHA" ]] \
|| fail "PROD_TARGET_BUILD_SHA_MISMATCH"
cd "$REMOTE_DIR" cd "$REMOTE_DIR"
[[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" && -r "$REMOTE_ENV_FILE" ]] \ [[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" && -r "$REMOTE_ENV_FILE" ]] \
+5 -13
查看文件
@@ -163,16 +163,6 @@ export function validateExactProductionIdentity({
assertExactIdentity("COMPOSE_PROJECT_NAME", composeProjectName, expectedComposeProjectName); assertExactIdentity("COMPOSE_PROJECT_NAME", composeProjectName, expectedComposeProjectName);
} }
function gitText(args) {
const result = spawnSync("git", args, {
cwd: ROOT_DIR,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) fail("CANDIDATE_COMMIT_UNAVAILABLE");
return result.stdout.trim();
}
function reportRef(reportDir) { function reportRef(reportDir) {
const relative = path.relative(ROOT_DIR, reportDir).replaceAll(path.sep, "/"); const relative = path.relative(ROOT_DIR, reportDir).replaceAll(path.sep, "/");
return relative && !relative.startsWith("..") ? relative : "controlled-production-preflight-report"; return relative && !relative.startsWith("..") ? relative : "controlled-production-preflight-report";
@@ -283,10 +273,10 @@ function main() {
expectedComposeProjectName, expectedComposeProjectName,
}); });
const candidateCommit = String( if (process.env.CANDIDATE_COMMIT == null) fail("CANDIDATE_COMMIT_MISSING");
process.env.CANDIDATE_COMMIT ?? gitText(["rev-parse", "--verify", "HEAD"]), const candidateCommit = String(process.env.CANDIDATE_COMMIT).trim();
).trim();
if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID"); if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID");
const candidateShortCommit = candidateCommit.slice(0, 12);
const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit); const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit);
const candidateSet = new Set(candidateManifest); const candidateSet = new Set(candidateManifest);
if (expectedPending.some((migration) => !candidateSet.has(migration))) { if (expectedPending.some((migration) => !candidateSet.has(migration))) {
@@ -315,6 +305,8 @@ function main() {
`STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`, `STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`,
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`, `EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`, `EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`,
`BUILD_SHA=${shellQuote(candidateCommit)}`,
`VITE_BUILD_SHA=${shellQuote(candidateShortCommit)}`,
`CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`, `CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`,
`EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`, `EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`,
"bash -s", "bash -s",
+6
查看文件
@@ -751,6 +751,9 @@ for (const requiredFragment of [
'validateProductionDbName', 'validateProductionDbName',
'collectMigrationManifest', 'collectMigrationManifest',
'EXPECTED_PENDING_MIGRATIONS', 'EXPECTED_PENDING_MIGRATIONS',
'CANDIDATE_COMMIT_MISSING',
'BUILD_SHA=',
'VITE_BUILD_SHA=',
'PRISMA_STATUS_OUTPUT_MISSING', 'PRISMA_STATUS_OUTPUT_MISSING',
'PRISMA_STATUS_OUTPUT_UNREADABLE', 'PRISMA_STATUS_OUTPUT_UNREADABLE',
'sanitizePrismaStatus', 'sanitizePrismaStatus',
@@ -767,6 +770,9 @@ for (const requiredFragment of [
for (const requiredFragment of [ for (const requiredFragment of [
'EXPECTED_PRODUCTION_ORIGIN_SHA256', 'EXPECTED_PRODUCTION_ORIGIN_SHA256',
'EXPECTED_PRODUCTION_DB_NAME_SHA256', 'EXPECTED_PRODUCTION_DB_NAME_SHA256',
'PROD_TARGET_BUILD_SHA_INVALID',
'PROD_TARGET_VITE_BUILD_SHA_INVALID',
'PROD_TARGET_BUILD_SHA_MISMATCH',
'PRISMA_STATUS_BEGIN', 'PRISMA_STATUS_BEGIN',
'FROM "_prisma_migrations"', 'FROM "_prisma_migrations"',
'logged_failure', 'logged_failure',
@@ -128,6 +128,8 @@ describe("deployment safety contract", () => {
expect(preflight).toContain("CANDIDATE_MIGRATIONS_CSV"); expect(preflight).toContain("CANDIDATE_MIGRATIONS_CSV");
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_MISSING"); expect(preflight).toContain("PRISMA_STATUS_OUTPUT_MISSING");
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE"); expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE");
expect(preflight).toContain("CANDIDATE_COMMIT_MISSING");
expect(preflight).not.toContain("rev-parse");
expect(preflight).toContain("redacted datasource line"); expect(preflight).toContain("redacted datasource line");
expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH"); expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH");
expect(preflight.lastIndexOf("printStableRemoteSummary(stdout)")).toBeLessThan( expect(preflight.lastIndexOf("printStableRemoteSummary(stdout)")).toBeLessThan(
@@ -140,6 +142,7 @@ describe("deployment safety contract", () => {
expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"); expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING");
expect(remote).toContain("logged_failure"); expect(remote).toContain("logged_failure");
expect(remote).toContain("PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY"); expect(remote).toContain("PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY");
expect(remote).toContain("PROD_TARGET_BUILD_SHA_MISMATCH");
expect(remote).toContain('FROM "_prisma_migrations"'); expect(remote).toContain('FROM "_prisma_migrations"');
expect(remote).toContain("rollbackWindow=OPEN"); expect(remote).toContain("rollbackWindow=OPEN");
expect(remote).not.toMatch(/\b(prisma migrate reset|prisma db push|prisma migrate resolve)\b/); expect(remote).not.toMatch(/\b(prisma migrate reset|prisma db push|prisma migrate resolve)\b/);
@@ -337,6 +340,8 @@ describe("deployment safety contract", () => {
expect(sshCommand).not.toContain("hyb_oa"); expect(sshCommand).not.toContain("hyb_oa");
expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256"); expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256");
expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256"); expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256");
expect(sshCommand).toContain(`BUILD_SHA='${candidateCommit}'`);
expect(sshCommand).toContain(`VITE_BUILD_SHA='${candidateCommit.slice(0, 12)}'`);
}); });
it("bootstraps only an exact historical identity and a unique refresh probe", async () => { it("bootstraps only an exact historical identity and a unique refresh probe", async () => {
@@ -494,7 +499,8 @@ describe("deployment safety contract", () => {
await chmod(path.join(fakeBin, "docker"), 0o700); await chmod(path.join(fakeBin, "docker"), 0o700);
const hash = (value) => createHash("sha256").update(value, "utf8").digest("hex"); const hash = (value) => createHash("sha256").update(value, "utf8").digest("hex");
const runRemote = (mode, expectedPending = "") => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], { const buildSha = "a".repeat(40);
const runRemote = (mode, expectedPending = "", buildOverrides = {}) => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], {
cwd: remoteDir, cwd: remoteDir,
env: { env: {
...process.env, ...process.env,
@@ -508,8 +514,11 @@ describe("deployment safety contract", () => {
STORAGE_HOST_DIR: storageDir, STORAGE_HOST_DIR: storageDir,
EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"), EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"),
EXPECTED_PRODUCTION_DB_NAME_SHA256: hash("production_db"), EXPECTED_PRODUCTION_DB_NAME_SHA256: hash("production_db"),
BUILD_SHA: buildSha,
VITE_BUILD_SHA: buildSha.slice(0, 12),
CANDIDATE_MIGRATIONS_CSV: "20260807193000_contract_registry_termination_workspace,20260807193001_followup_migration", CANDIDATE_MIGRATIONS_CSV: "20260807193000_contract_registry_termination_workspace,20260807193001_followup_migration",
EXPECTED_PENDING_MIGRATIONS_CSV: expectedPending, EXPECTED_PENDING_MIGRATIONS_CSV: expectedPending,
...buildOverrides,
}, },
}); });
const result = await runRemote("happy"); const result = await runRemote("happy");
@@ -524,6 +533,15 @@ describe("deployment safety contract", () => {
await expect(runRemote("logs")).rejects.toMatchObject({ await expect(runRemote("logs")).rejects.toMatchObject({
stderr: expect.stringContaining("PROD_MIGRATION_FAILED_OR_ROLLED_BACK"), stderr: expect.stringContaining("PROD_MIGRATION_FAILED_OR_ROLLED_BACK"),
}); });
await expect(runRemote("happy", "", { BUILD_SHA: "a".repeat(39) })).rejects.toMatchObject({
stderr: expect.stringContaining("PROD_TARGET_BUILD_SHA_INVALID"),
});
await expect(runRemote("happy", "", { VITE_BUILD_SHA: "a".repeat(7) })).rejects.toMatchObject({
stderr: expect.stringContaining("PROD_TARGET_VITE_BUILD_SHA_INVALID"),
});
await expect(runRemote("happy", "", { VITE_BUILD_SHA: "b".repeat(12) })).rejects.toMatchObject({
stderr: expect.stringContaining("PROD_TARGET_BUILD_SHA_MISMATCH"),
});
}); });
it("aggregates preflight checks and keeps the root helper self-contained", async () => { it("aggregates preflight checks and keeps the root helper self-contained", async () => {