上线前一天才发现的问题
发行事故很少是「某个功能没做」,更多是「某份材料没交、某个环境没验、某个人以为别人验过」。
常见场景:
- 渠道包已经提审,才发现支付回调地址还指向测试环境;
- 灰度 5% 跑了两天,回滚脚本从未演练;
- 隐私政策更新了,但 SDK 清单还是上个版本;
- 所有人都说「测试过了」,但没有任何一份可复现的记录。
这些问题不能靠「上线前再检查一遍」解决,因为检查本身也需要顺序、责任人与证据。本节把这件事写成一个门禁。
七个阶段
发行不是一步,而是七步:
| 阶段 | 要证明什么 | 典型证据 |
|---|---|---|
| content | 对外内容与素材齐备 | 商店截图、描述、宣传素材 |
| build | 构建可复现 | 产物哈希、签名报告 |
| channel | 渠道包正确 | 包清单、SDK 初始化测试 |
| payment | 收款链路可用 | 商品目录版本、凭证校验测试、订单幂等测试 |
| compliance | 合规材料齐备 | SDK 清单、权限清单、未成年人保护方案 |
| gray | 小流量验证通过 | 灰度报告、指标对比 |
| launch | 全量与回滚就绪 | 发布记录、回滚演练日志 |
七个阶段有依赖关系:构建不通过,渠道包没有意义;支付未验证,灰度放量只会放大损失;回滚未演练,全量发布就是一次没有退路的跳跃。
门禁的三条规则
规则一:必需项必须是 pass。
非必需项可以 partial 或 fail,但必需项不行。区分「必须有」与「最好有」,是门禁能执行的前提。
规则二:没有证据的 pass 降级为 partial。
「我确认过了」不是证据。证据必须能被第二个人复现:文件路径、测试输出、报表查询条件、演练日志。
规则三:空清单判失败,不默认通过。
这一条看起来多余,但它防的是最常见的事故:门禁脚本没接上,返回空列表,流程却显示「通过」。
把门禁写成代码
// file: src/release/release-gate.ts
// tier: L1
// verify: node --test test/release/release-gate.test.ts
export type ReleaseStatus = "pass" | "partial" | "fail";
export type ReleaseStage =
| "content"
| "build"
| "channel"
| "payment"
| "compliance"
| "gray"
| "launch";
export interface ReleaseItem {
readonly id: string;
readonly stage: ReleaseStage;
readonly required: boolean;
readonly status: ReleaseStatus;
readonly evidence: string;
}
export interface StageReport {
readonly stage: ReleaseStage;
readonly pass: number;
readonly partial: number;
readonly fail: number;
readonly blocked: boolean;
}
export interface ReleaseReport {
readonly ready: boolean;
readonly total: number;
readonly blocking: readonly ReleaseItem[];
readonly missingEvidence: readonly string[];
readonly stages: readonly StageReport[];
readonly reasons: readonly string[];
}
export const RELEASE_STAGES: readonly ReleaseStage[] = [
"content",
"build",
"channel",
"payment",
"compliance",
"gray",
"launch",
];
export function evaluateRelease(items: readonly ReleaseItem[]): ReleaseReport {
const reasons: string[] = [];
const normalized: ReleaseItem[] = [];
for (const item of items) {
if (item.status === "pass" && item.evidence.trim() === "") {
reasons.push(`${item.id}: pass without evidence counts as partial`);
normalized.push({ ...item, status: "partial" });
continue;
}
normalized.push(item);
}
const blocking = normalized.filter(
(item) => item.required && item.status !== "pass",
);
const missingEvidence = normalized
.filter((item) => item.evidence.trim() === "")
.map((item) => item.id);
const stages: StageReport[] = RELEASE_STAGES.map((stage) => {
const scoped = normalized.filter((item) => item.stage === stage);
const pass = scoped.filter((item) => item.status === "pass").length;
const partial = scoped.filter((item) => item.status === "partial").length;
const fail = scoped.filter((item) => item.status === "fail").length;
const blocked = scoped.some(
(item) => item.required && item.status !== "pass",
);
return { stage, pass, partial, fail, blocked };
});
if (normalized.length === 0) {
reasons.push("no release items");
}
if (blocking.length > 0) {
reasons.push(`${blocking.length} required item(s) block release`);
}
return {
ready: normalized.length > 0 && blocking.length === 0,
total: normalized.length,
blocking,
missingEvidence,
stages,
reasons,
};
}
export function blockingStages(report: ReleaseReport): readonly ReleaseStage[] {
return report.stages.filter((stage) => stage.blocked).map((stage) => stage.stage);
}
export function openStages(report: ReleaseReport): readonly ReleaseStage[] {
return report.stages
.filter((stage) => stage.pass === 0)
.map((stage) => stage.stage);
}
export function formatRelease(report: ReleaseReport): string {
const header = "stage\tpass\tpartial\tfail\tblocked";
const rows = report.stages.map(
(stage) =>
`${stage.stage}\t${stage.pass}\t${stage.partial}\t${stage.fail}\t${stage.blocked}`,
);
return [header, ...rows].join("\n");
}
export const DEFAULT_RELEASE: readonly ReleaseItem[] = [
{ id: "build-hash", stage: "build", required: true, status: "pass", evidence: "dist/latest.json" },
{ id: "build-sign", stage: "build", required: true, status: "pass", evidence: "sign/report.txt" },
{ id: "channel-packs", stage: "channel", required: true, status: "pass", evidence: "pack/manifest.json" },
{ id: "sdk-init-order", stage: "channel", required: true, status: "pass", evidence: "test/channel/sdk-contract.test.ts" },
{ id: "catalog-version", stage: "payment", required: true, status: "pass", evidence: "catalog/v42.json" },
{ id: "receipt-verify", stage: "payment", required: true, status: "pass", evidence: "test/biz/receipt-verify.test.ts" },
{ id: "order-idempotent", stage: "payment", required: true, status: "pass", evidence: "test/biz/order-fsm.test.ts" },
{ id: "privacy-sdk-list", stage: "compliance", required: true, status: "pass", evidence: "compliance/sdk-list.csv" },
{ id: "minor-protection", stage: "compliance", required: true, status: "pass", evidence: "compliance/minor-plan.md" },
{ id: "gray-5pct", stage: "gray", required: true, status: "pass", evidence: "gray/2026-11-05.json" },
{ id: "rollback-drill", stage: "launch", required: true, status: "pass", evidence: "drill/rollback-1105.log" },
{ id: "press-kit", stage: "content", required: false, status: "pass", evidence: "marketing/press-kit.zip" },
];
几个设计点值得说明。
为什么证据降级而不是直接判失败。 降级保留了「做了但没留证」与「完全没做」的区别。前者是流程问题,补记录即可;后者是能力缺失,需要停下来补工作。两者的处理优先级不同。
为什么按阶段汇总。 单个检查项失败,很难判断影响范围;按阶段汇总能直接回答「卡在哪一步」。blockingStages 返回被阻塞的阶段,openStages 返回一项都没通过的阶段。
为什么非必需项也要记录。 宣传素材今天不影响发布,但会影响发布后的买量。记录它,是为了在下一次迭代中补上,而不是假装它不存在。
一次方舟的上线检查
方舟准备赛季版本全量发布,门禁结果如下:
| 阶段 | pass | partial | fail | 是否阻塞 |
|---|---|---|---|---|
| content | 1 | 0 | 0 | 否 |
| build | 2 | 0 | 0 | 否 |
| channel | 2 | 0 | 0 | 否 |
| payment | 3 | 0 | 0 | 否 |
| compliance | 1 | 1 | 0 | 是 |
| gray | 1 | 0 | 0 | 否 |
| launch | 1 | 0 | 0 | 否 |
阻塞项是 minor-protection:方案写了,但没有人脸抽检的操作记录,状态是 partial。
这个结果会让发布停下来,但停得有价值:未成年人保护是合规红线,缺少操作记录意味着真出事时无法自证。补一份带时间与操作人的抽检记录,门禁即可通过。
如果门禁只输出「12 项中 11 项通过」,团队很可能直接发布;按阶段汇总后,问题位置一目了然。
与指标矩阵的分工
13.1 的矩阵检查「产品是否健康」,本节的门禁检查「这次发布是否安全」。
两者可能给出不同结论:
- 指标全绿、门禁阻塞:产品没问题,但发行材料或流程缺失,不能发。
- 门禁全绿、指标 fail:流程齐全,但产品数据不健康,不应该发。
- 两者都绿:可以发。
- 两者都红:先修产品,再补流程。
只有把两张表放在一起看,才能避免「流程正确地发布了一个错误版本」。
门禁的维护成本
门禁不是免费的。每增加一项检查,就多一份维护成本:
- 证据会过期,需要定期重跑;
- 渠道规则会变,检查项要跟着改;
- 检查太多,团队会开始绕过。
控制成本的办法是分层:必需项只保留真正阻塞发布的,其他项标记为非必需并定期清理。一个 12 项的门禁如果能稳定执行,比一个 60 项但没人看的清单更有价值。
小结
- 发行分七个阶段:内容、构建、渠道、支付、合规、灰度、发布。
- 三条规则:必需项必须 pass、无证据的 pass 降级、空清单判失败。
blockingStages定位卡住的阶段,openStages定位完全没开始的阶段。- 必需项与非必需项要分开,否则门禁会因维护成本被绕过。
- 门禁检查发布安全,指标矩阵检查产品健康,两者不能互相替代。
阅读导航:上一节:13.1 商业化指标矩阵 · 下一节:13.3 卷三评审与进入卷四 。
常见错误与边界
- 把门禁做成勾选清单。 没有证据的勾选等于没检查。
- 必需项过多。 所有项都必需,等于没有优先级,最后一定被绕过。
- 证据写成「已确认」。 证据必须是路径、输出或可查询的记录。
- 只跑一次门禁。 每次发布都要重跑,旧记录不能复用。
- 忽略非必需项。 它们不阻塞本次发布,但会影响下一次。
- 门禁与指标脱节。 流程通过不代表产品健康。
- 空清单默认通过。 脚本没接上时,这是最危险的结果。
- 回滚只写在文档里。 没有演练日志,回滚能力等于未知。
- 灰度没有退出条件。 只定放量比例,不定失败阈值。
- 把门禁交给一个人。 单点维护会随着人员变动失效。
- 证据不写版本。 同一个路径在不同版本下可能完全不同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。