16.3 持续集成与发布流水线

把前面各节散落的检查(配置合法性、必需文件、体积预算、禁浮点、命名规范、随机数可播种)串成一条发布前流水线。真实 CI 平台的执行不可复现,本节给的是同一套检查逻辑的本地可运行版本:读一份 D 层流水线定义,在本地逐条真跑,产出 JSON、CSV 与报告文本。

本章最后一节,把前两节散落的检查收口成一条发布流水线。先把分工说清楚:站内的 GitHub Actions 与 CI/CD 专题 讲的是 CI/CD 的通用工程实践——工作流语法、矩阵构建、缓存、secrets、runner;本节讲的是小游戏项目特有的发布约束——分包体积门禁、配置表校验、可玩原型回归、多平台产物的构建矩阵。这两层不冲突:通用机制决定「流水线怎么跑」,本节决定「跑哪些检查、哪些失败必须拦、哪些只告警」。

有一条边界必须先声明,否则读者会误以为本节在教你「怎么配 CI 平台」:真实 CI 平台上的流水线执行属不可复现(X)。同一份 YAML 在不同 runner、不同缓存状态、不同并发下行为不同,本机跑不出「平台的那条流水线」。所以本节给的不是平台配置,而是同一套检查逻辑的本地可运行版本——把流水线里每一条检查项抽成一个纯函数,在本地逐条真跑,让「发布前该绿的东西」在推到平台之前就绿。平台的流水线把这些检查再跑一遍,两边的判定口径一致。

D 层:发布流水线定义

下面这段是 D 层(静态层) 的流水线定义 config/e16_pipeline.json:四个阶段,每阶段列一组检查项与一个失败动作(block 表示失败即拦、warn 表示只告警)。它只是数据——真正执行这些检查的是下面那支 S 层脚本。注意 budgetKB 与 requiredFiles 也写在这里,让「体积预算是多少、哪些文件必须有」成为可被脚本读取的声明,而不是散落在脚本里的常量。

{
  "version": 1,
  "project": "merge-island",
  "seed": 20261012,
  "budgetKB": { "config": 64 },
  "requiredFiles": [
    "config/e16_schema.json",
    "config/e16_datatable.json",
    "config/e16_pipeline.json",
    "core/src/e16/boundary.ts",
    "core/src/e16/validate.ts",
    "core/test/e16_boundary.test.ts",
    "core/test/e16_validate.test.ts",
    "sim/e16_precheck.py"
  ],
  "stages": [
    { "id": "config-valid", "title": "配置文件合法性", "checks": ["json-parses", "no-floats", "table-matches-schema"], "onFailure": "block" },
    { "id": "files-present", "title": "必需文件齐备", "checks": ["required-files"], "onFailure": "block" },
    { "id": "size-budget", "title": "配置体积预算", "checks": ["size-budget"], "onFailure": "warn" },
    { "id": "code-hygiene", "title": "代码规范", "checks": ["naming-prefix", "no-random-in-core"], "onFailure": "block" }
  ]
}

四个阶段的分工:配置文件合法性(JSON 可解析、无浮点、数据表与 schema 表名一致)是硬门槛,配错了游戏根本跑不起来;必需文件齐备防止「漏提交一个文件、平台构建才发现」;配置体积预算是软告警,超一点不拦、超很多才该回头看;代码规范(本章文件一律 e16_ 前缀、core/ 里无 Math.random())是硬门槛,因为它守住的是「不与卷一 chNN 撞车」和「随机必须可播种」这两条本套书的底线。

S 层:本地发布前检查

下面这段是 S 层脚本 sim/e16_precheck.py:它读上面那份流水线定义,把每个检查项在本地真跑一遍,按阶段的 onFailure 判定是否阻塞,产出 JSON、CSV、报告文本与一张 SVG。它的价值在于可复现——同一份工作区、同一份脚本,每次得到同一份报告;这正是平台流水线做不到的(X)。

#!/usr/bin/env python3
# file: sim/e16_precheck.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e16_precheck.py --config config/ --out out/
"""S 层:发布前检查的**本地可运行版本**。

真实 CI 平台上的流水线执行属不可复现(X)——同样的 YAML 在不同 runner、
不同缓存、不同并发下行为不同,本机跑不出「平台的流水线」。本脚本把
config/e16_pipeline.json 里声明的阶段与检查项**在本地逐条真跑一遍**,
产出 JSON + CSV + 报告文本 + SVG,让「发布前该绿的东西」在推之前就绿。

检查项(与流水线声明一一对应):
  json-parses            config/e16_*.json 全部可解析
  no-floats              配置里无浮点字面量(跨语言逐位一致的前提)
  table-matches-schema   数据表 table 字段与 schema 一致
  required-files         流水线声明的必需文件齐备
  size-budget            配置总体积不超过 budgetKB.config
  naming-prefix          本章文件一律 e16_ 前缀(不与卷一 chNN 撞车)
  no-random-in-core      core/src/e16 下无 Math.random()(随机必须可播种)

全整数、无浮点;图表用纯 Python SVG。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import re
import xml.etree.ElementTree as ET

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  # pebble/
FLOAT_RE = re.compile(r"-?\d+\.\d+")


def strip_strings(line: str) -> str:
    line = re.sub(r'"(?:[^"\\]|\\.)*"', '""', line)
    line = re.sub(r"'(?:[^'\\]|\\.)*'", "''", line)
    return line


def config_files(cfg_dir: str) -> list[str]:
    return sorted(f for f in os.listdir(cfg_dir) if f.startswith("e16_") and f.endswith(".json"))


def check_json_parses(cfg_dir: str) -> tuple[str, str]:
    bad = []
    for f in config_files(cfg_dir):
        try:
            json.load(open(os.path.join(cfg_dir, f), encoding="utf-8"))
        except json.JSONDecodeError as e:
            bad.append(f"{f}:{e.lineno}")
    return ("pass", f"{len(config_files(cfg_dir))} 个配置均可解析") if not bad else ("fail", "解析失败 " + ",".join(bad))


def check_no_floats(cfg_dir: str) -> tuple[str, str]:
    hits = []
    for f in config_files(cfg_dir):
        for i, line in enumerate(open(os.path.join(cfg_dir, f), encoding="utf-8"), 1):
            m = FLOAT_RE.search(strip_strings(line))
            if m:
                hits.append(f"{f}:{i}:{m.group(0)}")
    return ("pass", "无浮点字面量") if not hits else ("fail", "浮点 " + ",".join(hits))


def check_table_matches_schema(cfg_dir: str) -> tuple[str, str]:
    schema = json.load(open(os.path.join(cfg_dir, "e16_schema.json"), encoding="utf-8"))
    data = json.load(open(os.path.join(cfg_dir, "e16_datatable.json"), encoding="utf-8"))
    if schema["table"] == data["table"]:
        return "pass", f"表名一致:{data['table']}({len(data['rows'])} 行)"
    return "fail", f"表名不符 {data['table']} != {schema['table']}"


def check_required_files(pipeline: dict) -> tuple[str, str]:
    missing = [p for p in pipeline["requiredFiles"] if not os.path.isfile(os.path.join(ROOT, p))]
    return ("pass", f"{len(pipeline['requiredFiles'])} 个必需文件齐备") if not missing else ("fail", "缺 " + ",".join(missing))


def check_size_budget(cfg_dir: str, pipeline: dict) -> tuple[str, str]:
    total = sum(os.path.getsize(os.path.join(cfg_dir, f)) for f in config_files(cfg_dir))
    budget = pipeline["budgetKB"]["config"] * 1024
    status = "pass" if total <= budget else "fail"
    return status, f"配置体积 {total} B / 预算 {budget} B"


def check_naming_prefix() -> tuple[str, str]:
    bad = []
    for d, pat in ((os.path.join(ROOT, "sim"), r"^e16_.+\.py$"),
                   (os.path.join(ROOT, "core", "test"), r"^e16_.+\.test\.ts$")):
        if not os.path.isdir(d):
            continue
        for f in sorted(os.listdir(d)):
            if "e16" in f and not re.match(pat, f):
                bad.append(f)
    return ("pass", "e16 文件命名规范") if not bad else ("fail", "命名不规范 " + ",".join(bad))


def strip_ts_comments(text: str) -> str:
    """剥掉 // 行注释与 /* */ 块注释,避免把注释里的示例文本当成真实调用。"""
    text = re.sub(r"/\*[\s\S]*?\*/", "", text)
    text = re.sub(r"//[^\n]*", "", text)
    return text


def check_no_random_in_core() -> tuple[str, str]:
    d = os.path.join(ROOT, "core", "src", "e16")
    hits = []
    if os.path.isdir(d):
        for f in sorted(os.listdir(d)):
            code = strip_ts_comments(open(os.path.join(d, f), encoding="utf-8").read())
            if "Math.random(" in code:
                hits.append(f)
    return ("pass", "core 无 Math.random()") if not hits else ("fail", "出现 Math.random():" + ",".join(hits))


CHECKS = {
    "json-parses": lambda cfg, pl: check_json_parses(cfg),
    "no-floats": lambda cfg, pl: check_no_floats(cfg),
    "table-matches-schema": lambda cfg, pl: check_table_matches_schema(cfg),
    "required-files": lambda cfg, pl: check_required_files(pl),
    "size-budget": lambda cfg, pl: check_size_budget(cfg, pl),
    "naming-prefix": lambda cfg, pl: check_naming_prefix(),
    "no-random-in-core": lambda cfg, pl: check_no_random_in_core(),
}


def write_svg(path: str, used: int, budget: int, blocked: int) -> int:
    W, H = 480, 150
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#0f172a"}).text = "配置体积 vs 预算"
    bar_max = W - 32
    w = bar_max * min(used, budget) // max(budget, 1)
    ET.SubElement(svg, "rect", {"x": "16", "y": "44", "width": str(bar_max), "height": "24", "fill": "#e2e8f0", "rx": "4"})
    ET.SubElement(svg, "rect", {"x": "16", "y": "44", "width": str(w), "height": "24", "fill": "#22c55e", "rx": "4"})
    ET.SubElement(svg, "text", {"x": "16", "y": "92", "font-size": "13", "fill": "#334155"}).text = f"used {used} B / budget {budget} B"
    color = "#16a34a" if blocked == 0 else "#dc2626"
    ET.SubElement(svg, "text", {"x": "16", "y": "120", "font-size": "13", "fill": color}).text = f"阻塞项失败 {blocked} 个"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(ROOT, args.config)
    out_dir = args.out if os.path.isabs(args.out) else os.path.join(ROOT, args.out)
    os.makedirs(out_dir, exist_ok=True)

    pipeline = json.load(open(os.path.join(cfg_dir, "e16_pipeline.json"), encoding="utf-8"))

    rows = []
    blocked = 0
    for stage in pipeline["stages"]:
        for cid in stage["checks"]:
            status, detail = CHECKS[cid](cfg_dir, pipeline)
            rows.append({"stage": stage["id"], "check": cid, "status": status, "detail": detail})
            if status == "fail" and stage["onFailure"] == "block":
                blocked += 1

    used = sum(os.path.getsize(os.path.join(cfg_dir, f)) for f in config_files(cfg_dir))
    budget = pipeline["budgetKB"]["config"] * 1024
    report = {
        "version": 1, "source": "sim/e16_precheck.py", "tier": "S",
        "project": pipeline["project"], "checks": len(rows),
        "passed": sum(1 for r in rows if r["status"] == "pass"),
        "failed": sum(1 for r in rows if r["status"] == "fail"),
        "blocked": blocked, "configBytes": used, "budgetBytes": budget,
        "results": rows,
    }
    json.dump(report, open(os.path.join(out_dir, "e16_precheck.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)

    with open(os.path.join(out_dir, "e16_precheck.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["stage", "check", "status", "detail"])
        for r in rows:
            w.writerow([r["stage"], r["check"], r["status"], r["detail"]])

    svg_bytes = write_svg(os.path.join(out_dir, "e16_precheck.svg"), used, budget, blocked)

    lines = [f"发布前检查(本地版,真实 CI 平台执行属 X): {len(rows)} 项"]
    for r in rows:
        lines.append(f"  [{'OK ' if r['status'] == 'pass' else 'FAIL'}] {r['stage']}/{r['check']}  {r['detail']}")
    lines.append(f"== 通过 {report['passed']} / 失败 {report['failed']} / 阻塞 {blocked};配置体积 {used} B / 预算 {budget} B")
    text = "\n".join(lines)
    open(os.path.join(out_dir, "e16_precheck.txt"), "w", encoding="utf-8").write(text + "\n")
    print(text)
    print(f"== SVG {svg_bytes} B -> out/e16_precheck.svg")
    return 1 if blocked else 0


if __name__ == "__main__":
    raise SystemExit(main())

脚本的退出码设计是这条流水线的关键:只要有阻塞项失败,return 1。平台流水线接到的正是一个非零退出码——本地跑绿,平台才不会红。软告警(体积预算超限)只记进报告,不改变退出码。

本机实跑 /opt/homebrew/bin/python3 sim/e16_precheck.py --config config/ --out out/ 的输出如下(同机相对量级,绝对数值不可移植):

发布前检查(本地版,真实 CI 平台执行属 X): 7 项
  [OK ] config-valid/json-parses  3 个配置均可解析
  [OK ] config-valid/no-floats  无浮点字面量
  [OK ] config-valid/table-matches-schema  表名一致:merge_items(8 行)
  [OK ] files-present/required-files  8 个必需文件齐备
  [OK ] size-budget/size-budget  配置体积 2536 B / 预算 65536 B
  [OK ] code-hygiene/naming-prefix  e16 文件命名规范
  [OK ] code-hygiene/no-random-in-core  core 无 Math.random()
== 通过 7 / 失败 0 / 阻塞 0;配置体积 2536 B / 预算 65536 B
== SVG 536 B -> out/e16_precheck.svg

七项检查全绿,退出码 0:三个配置均可解析、无浮点、表名一致(merge_items 8 行)、八个必需文件齐备、配置总体积 2536 B 远低于 64 KB 预算、e16_ 命名规范、core/ 无 Math.random()。这份报告可以整段贴进 PR 描述或发布记录——它同时是「这次提交通过了哪些检查」的证据。

顺带一个实现细节值得记下来:no-random-in-core 最初是直接 grep "Math.random(",结果把源码注释里那句「无 Math.random()」也当成了真实调用,报了个假阳性。修法是先剥掉 // 行注释与 /* */ 块注释再扫——这是所有「基于文本的检查」都要踩的坑:注释不是代码。同样地,no-floats 先剥字符串内容再扫数字,否则 "3.14" 这类描述文本会被误判。

三条纪律

  1. 检查项抽成纯函数,平台与本地共用一套逻辑。每个 check_* 都是「读文件 → 返回状态与说明」的纯函数,平台流水线调用它、本地脚本也调用它,两边口径一致;不把判定散进 YAML 的 shell 片段里。
  2. 失败动作分级。配错、缺文件、命名违规、随机不可播种是 block(拦),体积超预算是 warn(不拦)。分级决定退出码,也就决定了平台流水线是红是绿。
  3. 不可复现处显式标注 X。真实 CI 平台的执行、真实平台后台数据都标 X,本机跑不出;本节给的是同一套检查逻辑的本地可运行版本,能跑、能断言、能复现。

卷二收束

到这里,卷二《引擎、实现与体验打磨》就走完了。回看这条主线:从引擎选型(第 1 章)到工程骨架(第 2 章),从渲染、资源、循环、输入、物理、存档、内存、平台适配(第 3–10 章)到 UI、动效、引导、性能、测试(第 11–15 章),最后落在工程化协作(本章)——代码怎么组织、配置怎么校验、发布怎么收口。贯穿始终的是两条主张:一份 config,三种消费(仿真 / 内核 / 原型读同一组数字)与一份逻辑,两种宿主(mock 与真机逐帧一致)。它们不是口号,而是本节这条流水线每天在守的东西。

下一卷《上线变现与长线运营》接着回答:把这套工程做出来的游戏推上线、赚到钱、活得久。那里会有 eCPM 与 LTV 的仿真、漏斗与留存的模型、A/B 实验与赛季运营——而它们的输入,正是本卷这条流水线守住的配置与逻辑。

阅读导航:上一节:配置驱动与数据表 · 下一节:回到目录 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制