空跑实发了 nuget

九月二十七日早上,Loom 的发布管线做了一次”只打包、不发布”的空跑验证。运行结束,日志干净利落——八句 Your package was pushed.,2.5 秒内八个包连着推上了 nuget.org。

没有报错,没有红色。因为在这套流水线里,“空跑”和”发布”根本就是同一条路,那个本应拦住它的守卫,站在了副作用的下游。

现场:守卫的位置错了

首版 release.yml 的设计意图很正当:推 tag 才是正式发布,workflow_dispatch 手动触发则是空跑——只打包、传 artifact 供核验,不发布。守卫长这样:

        # 空跑:到此为止(包已上传 artifact 可下载核验,不发布)
        - name: Dry run guard
          if: github.event_name == 'workflow_dispatch'
          run: |
            echo "dry_run 空跑——包清单:"
            ls -la artifacts
            echo "未发布。正式发布 = 推 tag(preview-v* / v*)"
            exit 0

它挂在 publish job 里、OIDC 换票和推包之前——看起来位置也对。但实际运行时:守卫绿了、打印了”未发布”,然后流程没有停,下一步照常 OIDC 换票,再下一步照常推包。真正的原因只有一句话:

步骤里的 exit 0,结束的是这个步骤,不是这个 job。

步骤判成功,job 继续往下走。那个守卫有名字、有打印、有 exit 0,唯独没有”法力”。

修复只动一行——把守卫从”步骤”升格成job 级条件:

   publish:
     needs: build
-    if: github.repository == 'kernlab-dev/kernlab-loom'
+    # ★ 发布门:只有 tag 推送才发布——workflow_dispatch 空跑到此为止
+    #   (判例:步骤内 exit 0 只结束步骤不结束 job——旧守卫因此形同虚设,dry run 实发过一次)
+    if: github.event_name == 'push' && github.repository == 'kernlab-dev/kernlab-loom'

改后语义:条件为假时,整个 publish job 显示 skipped——从”打印一句未发布”变成”结构上没有这条腿”。

巧合:事故的内容,恰好就是计划的内容

事后复盘有个哭笑不得的细节:那天操作者填的 version 输入是 1.0.0-alpha.6——正是计划要发的版本。所以 pack 出来的八个包,就是计划中的八个包;“空跑”实发的内容与计划发版的内容完全重叠。README 里如实写道:

⚠️ 发布事故入档:release.yml 的 dry_run 守卫用步骤级 exit 0——不结束 job,导致空跑实发 nuget(本次恰为计划发版内容,追溯为 alpha.6 首发);门已改 job 级条件。

于是 alpha.6 成了一个”没有 tag 的版本”:git tag 列表里找不到它,nuget 上八个包入库时间错峰落在 08:37:19–08:44:11——与那一次空跑 run 互证。而 Endpoints / Di 两个包的首个版本号,就永远停在 alpha.6。

事故不管理论,它只管事实。这次值得庆幸的是:事实与计划重叠;下一次未必。

同一天的另一半:SDK 在跑步机上

那天 CI 还闹着另一场病,而且病根是上一篇的老朋友——静默。global.json 里钉着 8.0.4xx + latestFeature,看起来是”钉在 8.0.4xx 特性带”;实际被静默无视,runner 上新装的 SDK 10.0.401 直接生效(取证日志里 8.0.425 好端端躺在旁边,dotnet --version 却是 10.0.401)。

SDK 10 下的 pack 甩出那句”失败了但什么都不说”的错误:

error MSB4181: The "RestoreTask" task returned false but did not log an error.

六个连续失败 run,全红在 Pack 一步。取证、诊断、精确钉版(8.0.422,与本地验证环境同版,“171 绿同版”),中间还踩了 'main' is not a valid version string——手动触发时 GITHUB_REF_NAME 是分支名不是版本号,于是补了 version 输入,又顺手修了一个 YAML 引号引坏(''')的默认值。这一天的最终形态:构建/测试面钉 SDK 8、发布面另用 9.0.x(OIDC 需要)+ rm global.json 解钉——两个面、两个版本,显式分离。

后一天:发布腿被拒载两次

SDK 风波平了,家族决定把发布管线收进公共仓的 reusable workflow——逻辑单源,各仓只留调用桩。alpha.7 推 tag,publish 步骤 401:

Token exchange failed (HTTP 401) ... Claim 'job_workflow_ref' has value
'kernlab-dev/.github/.github/workflows/reusable-release.yml@refs/heads/main'
which does not start with kernlab-dev/kernlab-loom/.github/workflows/.

NuGet 的 Trusted Publishing 逐字校验 workflow 身份(owner/repository/workflow 一字不差),经 reusable 载体执行时,身份变成载体仓的路径——必 401。结论落档为家族纪律:“发布由各仓自己的 release.yml 执行,不可集中;可集中化的是 CI。“(旁证:家族 workflow-templates/ 里只有 ci 和 docs-deploy 两个模板,偏偏没有 release。)

发布腿收回本仓后,第二次拒载来得更彻底:零 job、零日志、1 秒 startup_failure——tag 推了三次才拿到真相:

  • 第一次(tag 在旧提交上):reusable 身份,401;
  • 第二次:startup_failure,日志是空的;
  • 根因:调用 reusable 时顶层只授了 contents: read,而 secrets: inherit 要求 caller 顶层显式授予 publish job 所需的 id-token: write——缺授权,GitHub 直接拒起整个 workflow(“零 job 零日志”就是这个意思);
  • 第三次:补上 id-token: write,绿。

alpha.7 那一小时内 tag 连推三次:401 → startup_failure → 全绿。这条判例后来被抄进了每个仓的 release.yml 注释(“alpha.7 实锤”)。

事后清点:两份坦白

把整个发布面盘了一遍,账面是这样的:

  • 10 个 preview-v* tag,0 个 v* tag——正式线(先全量测试再发)从管线存在起就没跑过一次;
  • alpha.6 无 tag(与空跑实发互证);alpha.11 既无 tag 也无包(跳号)。

坦白写进了 README,连带补救建议:“正式线(v* tag)全量测试在 nuget 首发后从未跑过——消费前建议推 v1.0.0-alpha.6 tag 走一遍全量门(包已被 --skip-duplicate 跳过,只跑测试)。“这条”公布自己的门禁债”的做法我们没有改得很漂亮——但把’从未跑过’写进 README,比让它在某次事故里被发现体面得多。

收尾:一条可迁移的判据

如果只能留一句给未来的自己:

“空跑”和”实发”如果不是两条物理隔离的路径,“空跑”迟早会实发——守卫的价值不在于它被写进文件,而在于它站在副作用的上游还是下游。

还有一句附则,是那两次 401 换来的:逐字绑定身份的能力(OIDC、发布权),共享的可以是逻辑,不能是身份。


本文事实来自 KernLab.Loom 的 release.yml 首版与修复 diff、dry-run run 的步骤级日志与 nuget.org 包时间线、SDK 钉版取证链(MSB4181 无日志失败)、alpha.7 三推的 run 记录(401 / startup_failure / 绿)、家族发布纪律与 reusable 管线对照,以及 README 的发布事故入档原文。

评论

← 全部文章