完善(08-09-deployment-contract-single-source-harness): 收敛部署合同并补失败路径
这个提交包含在:
@@ -18,6 +18,17 @@ const ALLOWED_UNPUBLISHED_REASONS = new Set([
|
||||
"controlled-local-recovery",
|
||||
]);
|
||||
|
||||
export function deriveCandidateBuildEnv(commit) {
|
||||
const normalized = String(commit ?? "").trim();
|
||||
if (!/^[0-9a-f]{40,64}$/u.test(normalized)) {
|
||||
throw new SourceProvenanceError("commit_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
BUILD_SHA: normalized,
|
||||
VITE_BUILD_SHA: normalized.slice(0, 12),
|
||||
});
|
||||
}
|
||||
|
||||
function runGit(rootDir, args) {
|
||||
return spawnSync("git", args, {
|
||||
cwd: rootDir,
|
||||
@@ -50,6 +61,7 @@ export function collectSourceProvenance({ rootDir, allowUnpublished = false }) {
|
||||
}
|
||||
|
||||
const commit = gitText(rootDir, ["rev-parse", "--verify", "HEAD"], "commit_unavailable");
|
||||
const buildEnv = deriveCandidateBuildEnv(commit);
|
||||
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;
|
||||
@@ -64,8 +76,9 @@ export function collectSourceProvenance({ rootDir, allowUnpublished = false }) {
|
||||
|
||||
return {
|
||||
status: "PASS",
|
||||
commit,
|
||||
shortCommit: commit.slice(0, 12),
|
||||
commit: buildEnv.BUILD_SHA,
|
||||
shortCommit: buildEnv.VITE_BUILD_SHA,
|
||||
buildEnv,
|
||||
branch,
|
||||
upstream,
|
||||
published,
|
||||
|
||||
+25
-4
@@ -127,8 +127,29 @@ 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_BUILD_ENV_LINES="$(node -e '
|
||||
const p = JSON.parse(process.argv[1]);
|
||||
for (const [key, value] of Object.entries(p.buildEnv ?? {})) {
|
||||
process.stdout.write(`${key}=${value}\n`);
|
||||
}
|
||||
' "$PROVENANCE_JSON")"
|
||||
declare -A SOURCE_BUILD_ENV=()
|
||||
while IFS='=' read -r key value; do
|
||||
[[ -n "$key" ]] || continue
|
||||
case "$key" in
|
||||
BUILD_SHA|VITE_BUILD_SHA) ;;
|
||||
*) echo "[ERROR] 发布来源包含未知候选构建环境键" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ -z "${SOURCE_BUILD_ENV[$key]+x}" ]] \
|
||||
|| { echo "[ERROR] 发布来源包含重复候选构建环境键" >&2; exit 1; }
|
||||
SOURCE_BUILD_ENV["$key"]="$value"
|
||||
done <<< "$SOURCE_BUILD_ENV_LINES"
|
||||
[[ "${#SOURCE_BUILD_ENV[@]}" -eq 2 \
|
||||
&& -n "${SOURCE_BUILD_ENV[BUILD_SHA]:-}" \
|
||||
&& -n "${SOURCE_BUILD_ENV[VITE_BUILD_SHA]:-}" ]] \
|
||||
|| { echo "[ERROR] 发布来源缺少完整候选构建环境对象" >&2; exit 1; }
|
||||
SOURCE_COMMIT="${SOURCE_BUILD_ENV[BUILD_SHA]}"
|
||||
SOURCE_SHORT_COMMIT="${SOURCE_BUILD_ENV[VITE_BUILD_SHA]}"
|
||||
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")"
|
||||
@@ -274,7 +295,7 @@ run_logged_deploy_stage() {
|
||||
echo "[deploy-stage][ERROR] 阶段名称格式无效" >&2
|
||||
return 2
|
||||
}
|
||||
local log_ref="$DEPLOY_RUNTIME_LOG_REF/${stage}-${BUILD_SHA:0:12}.log"
|
||||
local log_ref="$DEPLOY_RUNTIME_LOG_REF/${stage}-${VITE_BUILD_SHA}.log"
|
||||
local log_file="$REMOTE_DIR/$log_ref"
|
||||
: > "$log_file"
|
||||
chmod 0600 "$log_file"
|
||||
@@ -334,7 +355,7 @@ ready_ok=0
|
||||
for i in $(seq 1 60); do
|
||||
if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then
|
||||
if printf '%s' "$ready_payload" | "${compose_cmd[@]}" exec -T \
|
||||
-e EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" server node -e '
|
||||
-e EXPECTED_BUILD_SHA="${VITE_BUILD_SHA}" server node -e '
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
||||
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { deriveCandidateBuildEnv } from "./check-source-provenance.mjs";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "../..");
|
||||
@@ -275,8 +276,12 @@ function main() {
|
||||
|
||||
if (process.env.CANDIDATE_COMMIT == null) fail("CANDIDATE_COMMIT_MISSING");
|
||||
const candidateCommit = String(process.env.CANDIDATE_COMMIT).trim();
|
||||
if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID");
|
||||
const candidateShortCommit = candidateCommit.slice(0, 12);
|
||||
let candidateBuildEnv;
|
||||
try {
|
||||
candidateBuildEnv = deriveCandidateBuildEnv(candidateCommit);
|
||||
} catch {
|
||||
fail("CANDIDATE_COMMIT_INVALID");
|
||||
}
|
||||
const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit);
|
||||
const candidateSet = new Set(candidateManifest);
|
||||
if (expectedPending.some((migration) => !candidateSet.has(migration))) {
|
||||
@@ -305,8 +310,9 @@ function main() {
|
||||
`STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`,
|
||||
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
|
||||
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`,
|
||||
`BUILD_SHA=${shellQuote(candidateCommit)}`,
|
||||
`VITE_BUILD_SHA=${shellQuote(candidateShortCommit)}`,
|
||||
...Object.entries(candidateBuildEnv).map(
|
||||
([key, value]) => `${key}=${shellQuote(value)}`,
|
||||
),
|
||||
`CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`,
|
||||
`EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`,
|
||||
"bash -s",
|
||||
@@ -321,14 +327,6 @@ function main() {
|
||||
});
|
||||
const stdout = result.stdout || "";
|
||||
const rawStatus = extractPrismaStatus(stdout);
|
||||
if (rawStatus != null) {
|
||||
validatePrismaStatusText(rawStatus);
|
||||
const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]);
|
||||
writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
const stableSummary = stableRemoteSummary(stdout);
|
||||
writeFileSync(path.join(reportDir, "preflight-output.txt"), `${stableSummary}\n`, {
|
||||
encoding: "utf8",
|
||||
@@ -343,6 +341,12 @@ function main() {
|
||||
stderr: result.stderr,
|
||||
rawStatus,
|
||||
});
|
||||
validatePrismaStatusText(rawStatus);
|
||||
const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]);
|
||||
writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过");
|
||||
} catch (error) {
|
||||
const code = error instanceof ProductionPreflightError
|
||||
|
||||
@@ -287,6 +287,9 @@ const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath);
|
||||
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
|
||||
const productionPreflightScriptContent = readFile(productionPreflightScriptPath);
|
||||
const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath);
|
||||
const sourceProvenanceScriptContent = readFile(
|
||||
path.join(rootDir, 'deploy/trialrun/check-source-provenance.mjs'),
|
||||
);
|
||||
const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath);
|
||||
const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath);
|
||||
const dockerfileContent = readFile(dockerfilePath);
|
||||
@@ -453,6 +456,34 @@ for (const requiredFragment of [
|
||||
errors.push(`install-warch3-ops-timer.sh 缺少活动部署环境绑定: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'deriveCandidateBuildEnv',
|
||||
'BUILD_SHA: normalized',
|
||||
'VITE_BUILD_SHA: normalized.slice(0, 12)',
|
||||
'buildEnv,',
|
||||
]) {
|
||||
if (!sourceProvenanceScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`check-source-provenance.mjs 缺少候选构建身份单一合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
for (const requiredFragment of [
|
||||
'Object.entries(p.buildEnv ?? {})',
|
||||
'declare -A SOURCE_BUILD_ENV=()',
|
||||
'"${#SOURCE_BUILD_ENV[@]}" -eq 2',
|
||||
'SOURCE_COMMIT="${SOURCE_BUILD_ENV[BUILD_SHA]}"',
|
||||
'SOURCE_SHORT_COMMIT="${SOURCE_BUILD_ENV[VITE_BUILD_SHA]}"',
|
||||
]) {
|
||||
if (!deployScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`deploy.sh 未完整消费 provenance buildEnv: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
if (deployScriptContent.includes('p.shortCommit')
|
||||
|| deployScriptContent.includes('p.buildEnv.BUILD_SHA')
|
||||
|| deployScriptContent.includes('p.buildEnv.VITE_BUILD_SHA')
|
||||
|| deployScriptContent.includes('${BUILD_SHA:0:12}')) {
|
||||
errors.push('deploy.sh 不得绕过完整对象遍历单独读取候选构建字段');
|
||||
}
|
||||
if (productionIdentityBootstrapScriptContent.indexOf('if (identityOnly === "1")')
|
||||
> productionIdentityBootstrapScriptContent.indexOf('const databaseIdentityHash = bootstrapDatabaseIdentityHash')) {
|
||||
errors.push('production-identity-bootstrap.mjs 的 identity-only 停止点必须位于数据库身份读取前');
|
||||
@@ -765,8 +796,9 @@ for (const requiredFragment of [
|
||||
'collectMigrationManifest',
|
||||
'EXPECTED_PENDING_MIGRATIONS',
|
||||
'CANDIDATE_COMMIT_MISSING',
|
||||
'BUILD_SHA=',
|
||||
'VITE_BUILD_SHA=',
|
||||
'deriveCandidateBuildEnv',
|
||||
'Object.entries(candidateBuildEnv)',
|
||||
'([key, value]) => `${key}=${shellQuote(value)}`',
|
||||
'PRISMA_STATUS_OUTPUT_MISSING',
|
||||
'PRISMA_STATUS_OUTPUT_UNREADABLE',
|
||||
'sanitizePrismaStatus',
|
||||
@@ -779,6 +811,16 @@ for (const requiredFragment of [
|
||||
errors.push(`production-preflight.mjs 缺少生产目标/候选迁移合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
if (productionPreflightScriptContent.includes('candidateCommit.slice(0, 12)')) {
|
||||
errors.push('production-preflight.mjs 不得自行派生候选短 SHA');
|
||||
}
|
||||
if (productionPreflightScriptContent.includes('CANDIDATE_BUILD_ENV_KEYS')) {
|
||||
errors.push('production-preflight.mjs 不得以独立键数组重新定义候选构建环境对象');
|
||||
}
|
||||
if (productionPreflightScriptContent.lastIndexOf('assertRemotePreflightResult({')
|
||||
> productionPreflightScriptContent.lastIndexOf('validatePrismaStatusText(rawStatus);')) {
|
||||
errors.push('production-preflight.mjs 必须先传播远端失败,再校验 Prisma marker 内容');
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'EXPECTED_PRODUCTION_ORIGIN_SHA256',
|
||||
|
||||
在新工单中引用
屏蔽一个用户