负载和清单在有限保留窗口内,从明确的生产者交给执行验证的消费方。字节一致或文件名熟悉,本身不能证明来源或永久可用。

CI 产物需要明确的交接契约

CI 的上游任务可以成功结束,下游却拿到错误文件。产物是某个步骤生成并保存下来、交给其他任务、人员或发布流程使用的输出,仅靠名字通常不足以识别它。应把交接视为一份契约,说明生产它的运行、源码版本、负载内容、完整性证据、访问策略和可用时间窗口。这样,“下载构建结果”才成为能够核对的操作,而不是在共享目录里猜测哪个文件最新。 明确的交接核对预期生产者、约定文件与负载完整性,并规划保留期和访问。保留的通过记录只覆盖本地归档,未验证托管过期或来源真实性。本次绘图未重跑示例。 分清传递目的与保存期限 产物可以是二进制、测试报告、覆盖率数据,也可以是之后进入发布流程的软件包;不能断言它天然不属于发布输入。依赖缓存的目的则是加速可以重建的工作,二者不应混为一谈。选择存储和保留期之前,先确定审批、诊断和回滚需要哪些文件、需要多久。过期以后重新构建,可能生成不同字节,也需要重新验证,不能把重建视为取回原产物。 GitHub Actions 允许自定义产物保留时间,但受到仓库、组织或企业上限约束。安排流程时,要按真实配置考虑周末和延迟审批;请求一个较长保留值,并不能突破平台上限。确实需要长期留存的发布输入,应转入项目使用的持久发布存储或对象存储,并明确生命周期。短期任务存储即使今天能下载,也不应被说明成无限期的发布归档。 坏掉的样子 随手下载“最近一次成功运行”,可能拿到另一个提交、分支或工作流的文件。应明确解析目标生产任务,并核对它的源码与当前批准发布的变化之间的关系。下游还需要等待所需文件真正产生;空上传、缺失产物不能悄悄变成部署工作区里碰巧残留的旧文件。任务变绿与正确负载已经交付,是需要分别检查的事实。 完整性与来源真实性回答不同问题。校验和可以发现负载变化,但能同时替换文件和校验和的人,也能造出自洽的一对文件。来源声明还需要验证它指向的产物、签名方或工作流身份,以及项目要求的策略。二进制旁边存在一份证明文件,并不代表消费方已经执行了验证。必须看真实的验证结果,而不是只看相关配置或附件存在。 谁能访问产物,取决于平台、仓库可见性与授予的权限,不能一概等同于“能看到工作流的人”。即便如此,也只应上传交接必需的文件。凭据、环境变量全集、私有请求头和未经筛选的日志,一旦打包就可能留下额外副本。保存之前要检查成员列表及其内容,尤其当构建输入来自尚未信任的贡献时,不能只靠压缩包名称判断它是否适合交给下游。 快速验证 保存脚本为 artifacts.py 并执行 python3 artifacts.py,只需 Python 标准库。生产方在临时目录创建 ZIP,包含 app.txt 与清单;清单记录本地运行编号、示意性的源码摘要和负载摘要。消费方只读取约定成员,先比较预期生产者,再返回实际负载。这里没有解压任意归档成员,也没有连接真实 CI 平台。 from hashlib import sha256 from pathlib import Path from tempfile import TemporaryDirectory from zipfile import ZipFile import json payload = b'local build output\n' source_digest = sha256(b'example source revision\n').hexdigest() manifest = {'producer_run': 'local-run-41', 'source_sha256': source_digest, 'files': {'app.txt': sha256(payload).hexdigest()}} def produce(path, data): with ZipFile(path, 'w') as archive: archive.writestr('manifest.json', json.dumps(manifest, sort_keys=True)) archive.writestr('app.txt', data) def consume(path, expected_run): with ZipFile(path) as archive: if sorted(archive.namelist()) != ['app.txt', 'manifest.json']: raise ValueError('unexpected member set') received = json.loads(archive.read('manifest.json')) if (received['producer_run'] != expected_run or received['source_sha256'] != source_digest): raise ValueError('producer identity mismatch') data = archive.read('app.txt') if sha256(data).hexdigest() != received['files']['app.txt']: raise ValueError('payload digest mismatch') return data with TemporaryDirectory() as directory: archive = Path(directory) / 'handoff.zip' produce(archive, payload) assert consume(archive, 'local-run-41') == payload rejected = [] try: consume(archive, 'local-run-42') except ValueError as error: rejected.append(str(error)) produce(archive, payload + b'changed\n') try: consume(archive, 'local-run-41') except ValueError as error: rejected.append(str(error)) archive.unlink() try: consume(archive, 'local-run-41') except FileNotFoundError: rejected.append('artifact missing') assert rejected == ['producer identity mismatch', 'payload digest mismatch', 'artifact missing'] print(json.dumps({'valid_payload_bytes': len(payload), 'rejected': rejected, 'platform_retention_tested': False})) 正常交接返回十九字节。用 local-run-42 去领取实际来自 local-run-41 的归档,会得到生产者身份不匹配;保留旧清单、替换文件内容,会得到摘要不匹配;删除归档后继续下载,则得到文件缺失。最终结果必须列出这三种拒绝情况,任何失败都不会转而消费另一个构建。这样可以看清下游契约的失败分支确实执行了。 ...

九月 3, 2027 · 1 分钟 · XB Field Notes
必需作业的结果汇总到检查中。独立的仓库规则必须针对正确事件与版本要求对应检查通过;只有工作流 YAML 不会强制阻止合并。

GitHub Actions 工作流给构建把门槛

工作流能够为合并决定提供证据,但只有 YAML 并不会让这些证据成为强制要求。可靠的构建门禁需要连接正确的事件、实际测试的准确版本、有意义的测试结果,以及要求对应检查通过的仓库规则。审阅时应把这些环节一起核对,不能看到早先某次运行有绿色标志,就认为这次拟合并的内容已经满足当前要求。 汇总判断只是合并门禁的一部分:仓库规则必须针对正确事件与版本要求对应检查。保留的通过记录只覆盖本地 success 判断。本次绘图未重跑示例。 区分执行与强制要求 .github/workflows/ 下的文件描述触发器和作业。on 选择事件,分支保护或规则集则另行决定合并之前必须通过哪些检查。应记录稳定的检查名称,并在改名时同时审阅相应规则。不同工作流也应避免产生含糊的同名作业;如果规则指定检查来源,还需确认结果确实由预期的可信应用提供。 还要查看合并请求检查框显示的版本。pull_request 运行通常检查的是合成的合并提交,而不只是贡献者分支末端。合并队列为候选组合生成检查时,需要独立的 merge_group 事件。本地构建成功,或者手动触发过某份工作流,都不能自动替代规则要求的事件与版本;检查对象不一致时,即使输出相同,也不构成同一次验证。 明确依赖结果 没有依赖的作业可以并行运行,单个作业里的步骤通常顺序执行。needs 建立前置关系,条件控制失败或跳过之后哪些工作仍会执行。只有一个必需测试作业的工作流,并不需要人为增加依赖作业,也不必额外添加事件条件。因此,把缺少这些字段一律判成错误的检查器,无法准确判断门禁是否有效。 如果使用汇总门禁,应等待每个必需作业,并显式检查其结果。通过 if: ${{ always() }} 避免前置失败后只把门禁跳过,再要求结果确实为成功。如果某项可选工作允许跳过,应明确编码这个例外,而不是把所有缺失结果都视为通过。必需测试也不能随意开启忽略错误,否则一条普通的成功提示就可能掩盖没有完成的验证。 needs 上下文只包含直接依赖,不会包含依赖图中所有更早的作业。如果测试依赖构建,而门禁必须分别检查构建和测试的结果,就应在门禁的 needs 中直接声明这两个作业。仅仅因为构建是间接前置条件,并不意味着门禁可以从该上下文取得构建结果。 权限声明约束令牌,不约束所有凭据 permissions 控制的是 GITHUB_TOKEN,不会限制另行传给命令的其他秘密凭据。严格的工作流级声明可以由作业继承,不需要每个作业机械重复同样内容。只有真正需要的地方才增加权限。下面的离线实验没有检出私有源码,也不调用 GitHub 接口,因此不使用令牌;真实项目接入源码时,应重新检查实际最低权限需求。 普通的复刻仓库合并请求通常收到只读令牌,而不会收到仓库秘密,具体仍受仓库设置影响。“秘密只读”不是准确说法,令牌权限与秘密可用性是两个问题。不能为了取得秘密,就改用具有更高权限的 pull_request_target 并执行不可信的请求代码。日志遮盖只是辅助保护,不是打印凭据的许可,也不保证经过转换后的值绝不会泄漏。 坏掉的样子 整个工作流因路径或分支过滤没有运行时,对应必需检查可能一直等待;作业因条件而跳过,却可能在没有执行测试的情况下满足检查规则。这是两种不同结果。修改检查名称时应小心,并在托管服务上验证失败、跳过和取消路径,不能只用一次成功运行证明规则配置正确。检查能否出现、是否真正运行和能否阻止合并,需要分别确认。 缓存需要兼容的键,也需要按依赖声明执行可靠安装。普通合并请求产生的缓存属于该请求的合并引用,仅仅键相同,并不意味着基础分支可以恢复它。不能笼统声称任何复刻请求缓存都能污染主分支;应结合事件、引用范围和信任关系判断风险。另外,测试取消与部署恢复也不同:取消正在运行的作业,不会自动撤销它已经产生的外部变化,重试必须了解实际完成到了哪里。 快速验证 下面实验单独检查一项汇总门禁决定:只有前置作业的结果严格等于 success 才接受。在真实工作流中,这个值应来自 needs.test.result,并通过 needs 声明前置依赖。这里的输入则刻意采用合成状态,是本地判断练习,不是工作流校验器,也不能替代项目真实测试。它不会安装工作流或启动托管运行,只让这一项判定逻辑能够独立复现。 ...

九月 2, 2027 · 1 分钟 · XB Field Notes
在 pip 之前复制 app.py,会让仅源码变化也触发安装重跑。先复制依赖输入、安装,再复制 app.py,可复用该安装结果。

先复制依赖输入,再复制应用代码

Dockerfile 顺序决定昂贵构建操作依赖哪些先前输入。如果应用源码在依赖安装之前被复制,即使依赖声明未变,修改源码也可能让安装重跑。先复制依赖输入、安装依赖,再复制应用代码,可以建立更小的依赖边界,让它与安装器真正需要的工作匹配。这里优化的是依赖关系,而不只是把几行命令挪到看起来更整齐的位置。 依据原文历史样例说明机制。当前审核仍为已修订、执行受阻。本次绘图未执行 Docker 命令,未增加运行、页面、集成或发布验证。 坏掉的样子 在软件包安装前使用宽泛的 COPY . .,会让文档、生成输出或无关源码编辑,都可能成为重做昂贵工作的原因。问题不在于 COPY 天生很慢,而在于后续安装依赖了一份包含无关变化的文件系统状态。这些变化并不是选择和安装依赖所必需,却通过前置步骤扩大了失效范围。 如果安装还读取锁文件、本地包源码、补丁、工作区元数据或包管理器配置,仅复制一个清单也不够。遗漏真实输入,可能保留已经不符合构建意图的缓存结果。正确边界必须包含安装器实际使用的全部输入,而不是只选择常见 Dockerfile 示例里出现的某个文件名。否则,表面命中率提高,实际正确性却下降。 缓存顺序也不能修复可变依赖。相同安装命令可能一直保留旧结果,而干净构建却从远端解析到不同内容。固定版本和校验哈希解决产物选择问题,控制 COPY 顺序解决复用问题。两者都需要处理,构建更快并不能证明软件包图可复现,也不能证明依赖已经更新到本次发布要求的版本。 快速验证 使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。它先通过 HTTPS 下载两个公开的 idna wheel,并在构建前验证固定 SHA-256。构建 RUN 指令关闭外部网络,pip 从本地 wheel 目录安装并强制要求哈希;镜像解析仍可能访问仓库。实测环境为 Docker 29.3.1,Linux arm64 基础镜像内 Python 为 3.11.16: ...

八月 17, 2027 · 2 分钟 · XB Field Notes