开发环境合同与输入盘点
“把版本号发给我,我照着装”经常是环境故障的起点。一次原生模块编译失败,表面上是 Node.js 版本相同却结果不同;真正差异可能藏在 CPU 架构、编译器、系统 SDK、代理、证书或缓存。另一次测试只在远程工作区超时,本地同事反复重装依赖也无效,最后发现远端 DNS 无法解析共享服务域名。没有输入盘点,排障会围着最显眼的版本号打转。
环境合同把这种争论改成可比较事实:哪些输入必须一致,哪些只需记录,哪些敏感值绝不能进入证据,哪些外部服务必须可达。它既不是整台机器的镜像,也不是把所有 env 输出提交进仓库;它是一组可审查的声明、探针和失败语义。两次还原产生差异时,团队应能把差异归到工具链、平台、文件系统、配置或凭据、网络服务、数据状态与时间区域,而不是只得到一句“在我这里不复现”。
先把承诺拆成四个强度
最弱的承诺是工具版本固定,例如 node --version 与团队批准的发行线匹配。它没有说明 npm 依赖、原生动态库和外部服务。第二层是依赖闭包固定:包管理锁文件、来源摘要、镜像 Digest 或 Nix/Guix 闭包共同约束实际输入。第三层是构建输出可比较:在隔离环境重复构建,并对产物内容、元数据归一化和签名步骤分别检查。第四层才是运行行为等价:同一测试在允许的平台矩阵里得到相同业务结果,同时把内核、文件系统、时区、Locale、网络与硬件差异纳入解释。
合同应明确自己承诺到哪一层。普通 Web 项目往往要求工具链与依赖闭包一致、测试行为等价,但允许 Windows 与 Linux 产物字节不同;发布二进制则可能要求指定平台上的构建输出一致。承诺越强,隔离、存储和重复执行成本越高。将所有项目一律提升到字节级复现,会把大量预算花在并不影响交付的元数据上;把“测试通过”当成完整复现,又会遗漏来源和清理风险。
一份合同怎样改变采集结果
在仓库根创建 environment.contract.json。contractId 是长期稳定的项目环境身份,generation 只在锁定输入、支持矩阵或探针语义改变时递增。示例没有真实域名和凭据,APP_TOKEN 只检查存在性:
{
"schemaVersion": 1,
"contractId": "example/web-console-dev",
"generation": 1,
"platforms": ["win32", "darwin", "linux"],
"architectures": ["x64", "arm64"],
"tools": [
{
"name": "node",
"command": "node",
"args": ["--version"],
"versionPattern": "^v[0-9]+\\.",
"required": true
},
{
"name": "git",
"command": "git",
"args": ["--version"],
"versionPattern": "^git version [0-9]+\\.",
"required": true
}
],
"environment": [
{ "name": "APP_MODE", "required": true, "recordValue": true },
{ "name": "APP_TOKEN", "required": false, "sensitive": true }
],
"services": [
{ "name": "local-postgres", "host": "localhost", "port": 5432, "required": false }
],
"paths": [
{ "name": "project-manifest", "path": "package.json", "required": true }
],
"artifacts": [
{ "name": "npm-lock", "path": "package-lock.json", "role": "lockfile", "required": true }
]
}platforms 和 architectures 是执行矩阵,不是描述标签;当前机器不在集合中时,采集会失败。工具探针使用参数数组而不是一整段 Shell 字符串,避免额外的引号与展开差异。versionPattern 对命令第一行做正则判断,因此它应只表达团队真正依赖的版本形态;写得过宽会放过不兼容版本,写得过窄则让补丁升级无故阻断。
环境变量默认只记录是否存在。只有明确设置 recordValue: true、没有 sensitive: true,并且变量名不含常见凭据关键词时,脚本才写入值。名称规则只是误配置兜底,不替代合同评审;业务自定义的敏感字段仍须显式标记。服务探针验证 TCP 建连,不证明认证、协议握手或数据正确;项目还需执行一次真实读写。路径相对采集时的工作目录解析,能发现开发者从错误目录启动,却不会读取文件正文。required 决定探针失败是否进入总失败,适合把可选本地数据库与构建必需工具区分开。
artifacts 用于采集决定环境闭包的文件摘要,例如 package-lock.json、pnpm-lock.yaml、Cargo.lock、镜像 Digest 清单或内部工具校验清单。它不会把文件正文写进证据,只记录声明路径、角色、存在性和 SHA-256;目录、生成产物和凭据文件不应放进这里。采集器再用合同身份、平台架构、工具版本和这些摘要计算 inputFingerprint。这个指纹适合进入缓存键和证据索引,但不是数字签名,也不能证明下载内容来自可信发布者;来源签名、仓库权限和制品证明仍需由对应供应链工具验证。
安装并启用跨平台采集器
脚本只使用 Node.js 标准库,可在 Windows、macOS 与 Linux 上运行。先从 Node.js 下载页选择团队仍支持的发行线,安装后关闭并重新打开终端,让 PATH 更新生效:
node --version
git --version预期两条命令都输出版本。若提示找不到命令,先检查终端是否继承了新 PATH,再用 Get-Command node(PowerShell)或 command -v node(macOS/Linux)确认实际可执行文件;不要为了通过检查临时把未知目录放到 PATH 最前面。Node.js 暴露的 process.platform、process.arch 和 process.env 是脚本跨平台采集的基础,字段语义可在 Node.js Process 文档核对。
创建 scripts/env-contract.mjs:
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
const [, , action, ...args] = process.argv;
const EXIT_OPERATIONAL_ERROR = 1;
const EXIT_DRIFT = 2;
const EXIT_CONTRACT_VIOLATION = 3;
const EXIT_USAGE = 64;
const SENSITIVE_NAME = /(?:TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|COOKIE|CONNECTION_STRING|DATABASE_URL)/i;
function fail(message, code = EXIT_OPERATIONAL_ERROR) {
console.error(message);
process.exit(code);
}
function loadJson(file) {
const text = readFileSync(path.resolve(file), "utf8").replace(/^\uFEFF/, "");
return JSON.parse(text);
}
function validateContract(contract) {
if (contract.schemaVersion !== 1) throw new Error("unsupported schemaVersion");
if (typeof contract.contractId !== "string" || !contract.contractId.trim()) {
throw new Error("contractId must be a non-empty string");
}
if (!Number.isInteger(contract.generation) || contract.generation < 1) {
throw new Error("generation must be a positive integer");
}
for (const key of ["platforms", "architectures"]) {
if (
!Array.isArray(contract[key]) ||
contract[key].length === 0 ||
contract[key].some((item) => typeof item !== "string")
) {
throw new Error(`${key} must be a non-empty array`);
}
}
for (const key of ["tools", "environment", "services", "paths", "artifacts"]) {
if (contract[key] !== undefined && !Array.isArray(contract[key])) {
throw new Error(`${key} must be an array`);
}
}
const validateFlags = (spec, keys) => {
for (const key of keys) {
if (spec[key] !== undefined && typeof spec[key] !== "boolean") {
throw new Error(`${key} must be a boolean`);
}
}
};
for (const spec of contract.tools ?? []) {
if (
typeof spec.name !== "string" ||
typeof spec.command !== "string" ||
(spec.args && (!Array.isArray(spec.args) || spec.args.some((arg) => typeof arg !== "string")))
) {
throw new Error("each tool needs name, command and optional args array");
}
if (spec.versionPattern !== undefined && typeof spec.versionPattern !== "string") {
throw new Error("versionPattern must be a string");
}
if (spec.versionPattern) new RegExp(spec.versionPattern);
validateFlags(spec, ["required"]);
}
for (const spec of contract.environment ?? []) {
if (typeof spec.name !== "string") throw new Error("each environment entry needs name");
validateFlags(spec, ["required", "recordValue", "sensitive"]);
}
for (const spec of contract.services ?? []) {
if (
typeof spec.name !== "string" ||
typeof spec.host !== "string" ||
!Number.isInteger(spec.port) ||
spec.port < 1 ||
spec.port > 65535
) {
throw new Error("each service needs name, host and a valid TCP port");
}
validateFlags(spec, ["required"]);
}
for (const spec of contract.paths ?? []) {
if (typeof spec.name !== "string" || typeof spec.path !== "string") {
throw new Error("each path entry needs name and path");
}
validateFlags(spec, ["required"]);
}
for (const spec of contract.artifacts ?? []) {
if (
typeof spec.name !== "string" ||
typeof spec.path !== "string" ||
typeof spec.role !== "string"
) {
throw new Error("each artifact needs name, path and role");
}
validateFlags(spec, ["required"]);
}
return contract;
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function resolveProjectPath(declaredPath) {
const projectRoot = process.cwd();
const resolved = path.resolve(projectRoot, declaredPath);
const relative = path.relative(projectRoot, resolved);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error(`path escapes project root: ${declaredPath}`);
}
return resolved;
}
function probeArtifact(spec) {
const resolved = resolveProjectPath(spec.path);
const exists = existsSync(resolved);
return {
name: spec.name,
role: spec.role,
declaredPath: spec.path,
required: Boolean(spec.required),
exists,
digest: exists ? `sha256:${sha256(readFileSync(resolved))}` : null
};
}
function probeTool(spec) {
const result = spawnSync(spec.command, spec.args ?? [], {
encoding: "utf8",
shell: false,
timeout: 5000
});
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
const firstLine = output.split(/\r?\n/, 1)[0] ?? "";
const found = !result.error;
const exited = result.status === 0;
const versionMatches = spec.versionPattern
? new RegExp(spec.versionPattern).test(firstLine)
: exited;
return {
name: spec.name,
required: Boolean(spec.required),
found,
exitCode: result.status,
version: firstLine,
versionMatches,
ok: found && exited && versionMatches
};
}
function probeFilesystem() {
const root = mkdtempSync(path.join(os.tmpdir(), "env-contract-"));
try {
writeFileSync(path.join(root, "CaseProbe"), "probe", "utf8");
return { caseSensitive: !existsSync(path.join(root, "caseprobe")) };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function probeService(spec) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: spec.host, port: spec.port });
let settled = false;
const finish = (reachable, detail) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({
name: spec.name,
host: spec.host,
port: spec.port,
required: Boolean(spec.required),
reachable,
detail
});
};
socket.setTimeout(1500);
socket.once("connect", () => finish(true, "connected"));
socket.once("timeout", () => finish(false, "timeout"));
socket.once("error", (error) => finish(false, error.code ?? "error"));
});
}
function collectEnvironment(specs = []) {
return specs.map((spec) => {
const present = Object.hasOwn(process.env, spec.name);
const sensitive = Boolean(spec.sensitive) || SENSITIVE_NAME.test(spec.name);
const item = {
name: spec.name,
required: Boolean(spec.required),
sensitive,
present
};
if (present && spec.recordValue && !sensitive) {
item.value = process.env[spec.name];
}
return item;
});
}
async function capture(contractFile, outputFile) {
const contract = validateContract(loadJson(resolveProjectPath(contractFile)));
const tools = (contract.tools ?? []).map(probeTool);
const environment = collectEnvironment(contract.environment);
const services = await Promise.all((contract.services ?? []).map(probeService));
const artifacts = (contract.artifacts ?? []).map(probeArtifact);
const paths = (contract.paths ?? []).map((spec) => {
const resolved = resolveProjectPath(spec.path);
return {
name: spec.name,
required: Boolean(spec.required),
declaredPath: spec.path,
exists: existsSync(resolved)
};
});
const fingerprintInput = {
contractId: contract.contractId,
generation: contract.generation,
platform: process.platform,
architecture: process.arch,
tools: tools.map(({ name, version }) => ({ name, version })),
artifacts: artifacts.map(({ name, role, digest }) => ({ name, role, digest }))
};
const evidence = {
schemaVersion: contract.schemaVersion,
identity: {
contractId: contract.contractId,
generation: contract.generation,
inputFingerprint: `sha256:${sha256(JSON.stringify(fingerprintInput))}`
},
host: {
platform: process.platform,
architecture: process.arch,
kernelRelease: os.release()
},
runtime: {
node: process.version,
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
},
filesystem: probeFilesystem(),
tools,
environment,
services,
paths,
artifacts
};
const violations = [];
if (!(contract.platforms ?? []).includes(process.platform)) {
violations.push(`unsupported platform: ${process.platform}`);
}
if (!(contract.architectures ?? []).includes(process.arch)) {
violations.push(`unsupported architecture: ${process.arch}`);
}
for (const item of tools) {
if (item.required && !item.ok) violations.push(`tool failed: ${item.name}`);
}
for (const item of environment) {
if (item.required && !item.present) violations.push(`environment missing: ${item.name}`);
}
for (const item of services) {
if (item.required && !item.reachable) violations.push(`service unreachable: ${item.name}`);
}
for (const item of paths) {
if (item.required && !item.exists) violations.push(`path missing: ${item.name}`);
}
for (const item of artifacts) {
if (item.required && !item.exists) violations.push(`artifact missing: ${item.name}`);
}
evidence.violations = violations;
writeFileSync(resolveProjectPath(outputFile), `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
console.log(`evidence: ${outputFile}`);
console.log(violations.length ? `violations: ${violations.length}` : "contract: satisfied");
process.exitCode = violations.length ? EXIT_CONTRACT_VIOLATION : 0;
}
function category(key) {
if (key.startsWith("host.platform") || key.startsWith("host.architecture")) return "platform";
if (key.startsWith("host.kernelRelease")) return "kernel";
if (key.startsWith("filesystem") || key.startsWith("paths")) return "filesystem";
if (key.startsWith("runtime")) return "locale-or-runtime";
if (key.startsWith("tools")) return "toolchain";
if (key.startsWith("environment")) return "configuration-or-credential";
if (key.startsWith("services")) return "network-or-service";
if (key.startsWith("identity") || key.startsWith("artifacts")) return "locked-input";
return "contract";
}
function differences(left, right, key = "") {
if (Object.is(left, right)) return [];
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
return [{ key, category: category(key), left, right }];
}
const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort();
return keys.flatMap((child) =>
differences(left[child], right[child], key ? `${key}.${child}` : child)
);
}
function compare(leftFile, rightFile) {
const diff = differences(
loadJson(resolveProjectPath(leftFile)),
loadJson(resolveProjectPath(rightFile))
);
if (diff.length === 0) {
console.log("equivalent: no differences");
return;
}
for (const item of diff) {
console.log(`[${item.category}] ${item.key}: ${JSON.stringify(item.left)} -> ${JSON.stringify(item.right)}`);
}
console.log(`drift: ${diff.length} difference(s)`);
process.exitCode = EXIT_DRIFT;
}
try {
if (action === "capture" && args.length === 2) {
await capture(args[0], args[1]);
} else if (action === "compare" && args.length === 2) {
compare(args[0], args[1]);
} else {
fail(
"usage: node env-contract.mjs capture <contract.json> <evidence.json> | compare <left.json> <right.json>",
EXIT_USAGE
);
}
} catch (error) {
console.error(`error: ${error.message}`);
process.exitCode = EXIT_OPERATIONAL_ERROR;
}脚本不会调用 Shell,也不会把 args 解释成命令文本;工具探针只把显式的 command 与参数数组交给操作系统执行。这降低了引号注入风险,但合同仍能指定任意本地可执行文件,合并前必须代码评审。路径与锁定输入被限制在当前项目根目录内,../ 逃逸或项目外绝对路径会以操作错误退出,避免外部 PR 探测宿主文件;输出位置仍由调用者指定,父目录应先存在且 CI 应固定到项目制品目录。采集器不会自动上传,也不会扫描整个环境变量集合。环境变量值有显式和名称双重保护,但工具版本输出、平台信息、锁文件摘要与服务地址仍可能暴露工程情报,证据不能默认公开。摘要由 Node.js 标准库的 crypto.createHash计算,它用于变化检测,不替代签名校验。
退出码把不同失败面分开:0 表示采集满足合同或两份证据等价,1 表示 JSON、正则、文件权限或其他执行错误,2 表示比较发现漂移,3 表示采集完成但必需探针不满足,64 表示命令用法错误。CI 应分别保留 1 和 3 的日志;前者通常没有可信证据,后者应已有可供排障的输出文件。
正向实验:同一合同得到等价证据
先创建证据目录并设置公开样例值。PowerShell 执行:
New-Item -ItemType Directory -Force .env-evidence | Out-Null
$env:APP_MODE = "development"
node scripts/env-contract.mjs capture environment.contract.json .env-evidence/baseline.json
node scripts/env-contract.mjs capture environment.contract.json .env-evidence/candidate.json
node scripts/env-contract.mjs compare .env-evidence/baseline.json .env-evidence/candidate.json
$LASTEXITCODEmacOS 或 Linux 执行:
mkdir -p .env-evidence
export APP_MODE=development
node scripts/env-contract.mjs capture environment.contract.json .env-evidence/baseline.json
node scripts/env-contract.mjs capture environment.contract.json .env-evidence/candidate.json
node scripts/env-contract.mjs compare .env-evidence/baseline.json .env-evidence/candidate.json
printf 'exit=%s\n' "$?"两次采集都应显示 contract: satisfied,比较输出应为:
equivalent: no differences退出码应为 0。打开证据文件可以看到合同身份与输入指纹、平台、架构、内核、Node.js、Locale、时区、文件系统大小写、工具版本、锁文件摘要、变量存在性、服务连通性和项目路径。APP_TOKEN 即使存在也只显示 present: true,不会出现值;即便误写 recordValue: true,名称兜底也会阻止它落盘。local-postgres 没启动时会记录 ECONNREFUSED 或超时,但因为 required: false 不阻断合同;当项目确实依赖本地数据库时,应把它改成必需,并追加协议级读写探针。
如果两次紧邻采集仍然不同,先看分类而不是删除字段。kernel 变化通常表示采集发生在不同宿主或系统升级前后;locale-or-runtime 指向时区、Locale 或引导器漂移;toolchain 表示命令解析或版本不同;filesystem 常见于工作目录和大小写语义;network-or-service 说明端口状态改变。每一类都对应不同修复动作,不能统一用“重装依赖”处理。
反向实验:缺失输入必须留下失败证据
从全绿状态移除必需的 APP_MODE。PowerShell:
Remove-Item Env:APP_MODE -ErrorAction SilentlyContinue
node scripts/env-contract.mjs capture environment.contract.json .env-evidence/missing.json
$LASTEXITCODE
node scripts/env-contract.mjs compare .env-evidence/baseline.json .env-evidence/missing.json
$LASTEXITCODEmacOS 或 Linux:
unset APP_MODE
node scripts/env-contract.mjs capture environment.contract.json .env-evidence/missing.json
printf 'capture_exit=%s\n' "$?"
node scripts/env-contract.mjs compare .env-evidence/baseline.json .env-evidence/missing.json
printf 'compare_exit=%s\n' "$?"采集仍会写出 missing.json,这样失败现场不会因非零退出而丢失;随后打印 violations: 1 并退出 3。比较至少应包含:
[configuration-or-credential] environment.0.present: true -> false
[configuration-or-credential] environment.0.value: "development" -> undefined
[contract] violations.0: undefined -> "environment missing: APP_MODE"
drift: 3 difference(s)比较退出 2,表示证据存在但不等价。若采集反而退出 0,检查合同中的变量名、required 布尔值和当前工作目录;若输出文件没有生成,检查父目录权限和 JSON 语法。若 APP_TOKEN 的值出现在文件中,说明有人错误地同时使用了自定义采集逻辑或去掉了 sensitive 保护,证据应立即删除并按凭据泄露流程撤销令牌。
再做一个服务反例时,不要用真实共享数据库。可以把样例端口改成一个确认未监听的本地端口并设为必需,预期证据出现 ECONNREFUSED 或 timeout,采集退出 3。ECONNREFUSED 说明目标主机可达但端口没有接受连接;超时更可能涉及防火墙、路由或地址选择。TCP 连通后认证仍可能失败,因此真实项目应把“端口可达”和“最小权限读写”作为两个独立状态。
锁定输入也要做反例。不要直接改团队正在评审的锁文件;复制仓库到临时工作区,在副本的 package-lock.json 末尾添加一个换行,再采集 lock-drift.json 并与基线比较。JSON 语义可能没有变化,但 artifacts.0.digest 与 identity.inputFingerprint 都应变化,比较退出 2。这证明缓存键和环境身份绑定的是实际读取字节,而不是“文件名存在”。实验后删除临时副本,不要用格式化命令覆盖真实锁文件。若团队决定只有依赖图变化才应失效,应由包管理器解析锁文件并生成规范化摘要,不能简单忽略原始摘要差异。
把差异分类到真正的修复层
工具链差异包括命令不存在、PATH 指向另一安装、版本正则不匹配和包闭包变化。修复动作是回到批准的版本管理器、锁文件、Store、镜像 Digest 或安装来源,不是把候选机器上的全局包复制过去。原生扩展还要记录编译器、系统 SDK 与动态库;只有 JavaScript 依赖树相同,不能解释原生二进制是否兼容。
平台与内核差异包括操作系统、CPU 架构、内核版本、系统调用和硬件加速。跨平台项目应维护允许矩阵与平台专用锁输出;只支持单一平台的项目应尽早拒绝其他平台。容器能固定用户空间,却不能抹掉宿主内核和 CPU;VM 能进一步隔离内核,仍要处理共享目录、网络与设备。
文件系统差异常以大小写、路径长度、权限位、换行、符号链接和挂载一致性出现。本文脚本只测大小写与路径存在,项目可增加临时目录写入、符号链接和文件锁探针。不要递归上传目录列表来“证明一致”,那会泄露文件名并制造巨大证据;应为具体风险增加最小探针。
配置与凭据差异要分开处置。普通配置可以记录经过审查的非敏感值,凭据只记录存在性、来源类型和权限主体;令牌正文、私钥、Cookie、数据库连接串和云密钥不能进入证据。存在性相同也不代表权限相同,受控环境应再执行一次最小权限操作,并在服务端审计日志中确认主体。
网络与服务差异涉及 DNS、代理、CA、端口、出口策略、服务版本和数据状态。TCP 失败先区分解析失败、拒绝和超时;TLS 失败继续检查证书链、主机名与代理;认证失败检查身份和权限;业务读写失败再进入协议和数据层。逐层探针能避免把所有网络问题都归咎于防火墙。
时间与区域差异包括时区、Locale、系统时钟和随机源。测试使用 T0 与 T+N 语义时间,业务代码显式传入时钟;需要比较产物时归一化文件元数据。不要为了让测试通过全局修改开发机时区,这会影响其他项目。硬件、外部 SaaS 和共享数据库无法完整封装时,应记录能力类别、测试替身与真实集成验证入口,并承认它们是声明外输入。
接进项目任务与 CI
将合同和脚本纳入代码评审,并把证据目录加入项目忽略规则;基线若需要提交,应先确认没有主机名、用户目录和敏感配置。package.json 可以提供清晰入口:
{
"scripts": {
"env:capture": "node scripts/env-contract.mjs capture environment.contract.json .env-evidence/current.json",
"env:compare": "node scripts/env-contract.mjs compare .env-evidence/baseline.json .env-evidence/current.json",
"env:verify": "npm run env:capture && npm run env:compare"
}
}本机首次启用时由维护者生成对应平台基线,另一位成员在干净环境复跑。CI 不应拿 Windows 基线强行比较 Linux Runner;可以按平台和架构选择基线文件,或者只对合同声明的稳定字段做比较。一次可信作业先从空缓存还原依赖,再采集合同、运行项目测试和清理探针;后续作业可以测热缓存,但缓存键必须包含合同代际、平台、架构、锁定输入和构建器身份。缓存恢复是一个输入步骤,不是验证结论:命中后仍采集合同并核对 inputFingerprint。宽泛的 restore-keys 可能恢复旧闭包,只能作为下载加速,后续安装或构建必须重新校验;低信任 PR 不应拥有写入主线缓存的身份。GitHub Actions 缓存文档明确区分精确命中与前缀回退,也提醒缓存内容可能被可读缓存的工作流提取,因此其中不能保存凭据。
steps:
- uses: actions/checkout@<approved-immutable-reference>
- uses: actions/setup-node@<approved-immutable-reference>
with:
node-version-file: .node-version
- run: npm ci
- run: npm run env:verify
- run: npm test自动化身份只需要读取仓库、下载依赖和访问测试服务的最小权限。Fork 与外部 PR 不注入写权限令牌;合同变更、脚本变更和基线更新需要 owner 审查。证据作为 CI 制品时设置短保留期和访问控制,因为工具版本、平台和服务拓扑仍属于工程情报。日志输出禁止打印完整环境变量,失败命令也不要使用会回显 Secret 的调试开关。
容量、成本和长期维护
盘点本身开销很小,真正成本来自重复冷构建、跨平台 Runner、VM 或远程工作区、缓存存储和证据保留。团队应观察采集耗时、冷还原耗时、缓存下载字节、精确命中与前缀回退比例、失败探针数量、各平台队列时间与证据存储增长。高频 PR 可以运行工具、配置和服务的快速探针;重复构建、离线恢复和完整销毁放在周期性环境测试中,但合并门仍要拒绝必需输入缺失。若热缓存持续成功而冷还原失败,说明团队维护的是缓存库存而不是可复现环境,应立即阻断缓存写入并修复来源或锁定输入。
合同需要明确 owner。升级运行时、改变平台矩阵、增加服务或修改敏感字段时,owner 先在隔离分支更新合同和对应基线,再让代表性项目验证。旧基线保留到回退窗口结束;若新版本导致原生扩展、证书或缓存不兼容,恢复旧合同与锁文件,并重新生成工作区,而不是手工修补已漂移环境。
长期治理还要防止合同腐化。某个探针长期为可选且从未成功,应决定删除、修复或提升为必需;某个变量不再被代码读取,应从合同和凭据系统同时回收;某个平台没有 owner 和 Runner,就不应继续承诺支持。季度或发行里程碑检查应从失败证据与真实支持能力出发,不以字段数量衡量成熟度。
清理实验并证明没有残留
实验结束先删除证据文件,再清除当前终端中的样例变量。PowerShell:
$root = [IO.Path]::GetFullPath((Get-Location).Path)
$evidence = [IO.Path]::GetFullPath((Join-Path $root ".env-evidence"))
if ([IO.Path]::GetDirectoryName($evidence) -ne $root) { throw "unexpected cleanup path" }
if (Test-Path -LiteralPath $evidence) {
$item = Get-Item -LiteralPath $evidence -Force
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "refuse to remove a link" }
Remove-Item -LiteralPath $evidence -Recurse -Force
}
Remove-Item Env:APP_MODE -ErrorAction SilentlyContinue
Remove-Item Env:APP_TOKEN -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $evidence) { throw "evidence directory still exists" }
if ((Test-Path Env:APP_MODE) -or (Test-Path Env:APP_TOKEN)) { throw "sample variable still exists" }macOS 或 Linux:
if [ -L .env-evidence ]; then
printf '%s\n' 'refuse to remove a symbolic link' >&2
exit 1
fi
if [ -d .env-evidence ]; then
rm -rf -- .env-evidence
fi
unset APP_MODE APP_TOKEN
test ! -e .env-evidence
test -z "${APP_MODE+x}" && test -z "${APP_TOKEN+x}"最后一条检查应成功,当前终端也不再持有样例变量。若实验曾启动本地服务,先用项目自己的停止命令结束,再查询端口;不要删除共享数据库、全局包缓存、Nix Store 或他人工作区。脚本的文件系统探针会在 finally 中删除临时目录,即使探针失败也不会主动清理项目外资源。
合同回退不是删除证据,而是恢复上一份经过验证的合同、锁文件和平台基线,重新采集并确认比较退出 0。当项目退役时,删除合同、脚本、CI 入口和专用基线,同时撤销自动化身份、删除远端工作区与无引用缓存。只有“新环境可还原、错误输入会失败、旧版本能恢复、退役资源查不到”同时成立,环境合同才真正从文档变成工程能力。
