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 的归档,会得到生产者身份不匹配;保留旧清单、替换文件内容,会得到摘要不匹配;删除归档后继续下载,则得到文件缺失。最终结果必须列出这三种拒绝情况,任何失败都不会转而消费另一个构建。这样可以看清下游契约的失败分支确实执行了。

这是实际的本地归档实验,没有执行 GitHub 的过期服务,也没有生成或验证来源证明。示例源码摘要只是标识符,不是已经核实的 Git 提交。能改写清单的一方仍可能构造一致的谎言。本地删除模拟的是“文件不存在”,不能证明平台会在某个准确时刻执行过期清理。这些界限应随结果一起说明,避免把有限检查扩大成完整供应链验证。

把消费方的发布门写清楚

需要阅读实际下载组件怎样处理失败。GitHub 文档说明,摘要不一致时会在界面和任务日志中展示警告,不能直接假设所有完整性警告都会让部署失败。若发布策略要求拒绝,就应落实对应门禁,并把结果与生产者身份一起保存。还要记录持久发布字节放在哪里、谁能取回,以及临时交接副本什么时候可以过期;这些信息才能让后续审批和恢复真正依赖同一份产物。

延伸阅读

来源