ci(release): 自建 GitHub Release,body 截断到 125k 以内,并把 ADR-0087 D4 重新挂上 (#4900) - #5290
Merged
Conversation
…e 125k limit (#4900) changesets/action's `createGithubReleases` posts each package's raw CHANGELOG section as the Release body. @objectstack/spec's section for a single v17 RC is 342,893 characters against the API's 125,000 limit, so the POST 422'd — inside runPublish, i.e. after `changeset publish` had fully succeeded but BEFORE the action set its `published` output. The step went red, `published` stayed false, and the docker job gated on it was skipped: a published npm version with no runtime image. The section only grows, so this failed identically every release in the window. Measured against the live API: @objectstack/spec has NO Release for 17.0.0-rc.0, rc.1 or rc.2 (all 404 by tag), while 16.0.0 and 16.1.0 — 62,886 and 1,523 characters — have theirs, each carrying the ADR-0087 D4 spec-changes.json asset. That asset uploads ONTO the spec Release, so D4 has been silently unmounted for the whole v17 RC window too, not just the Release. `createGithubReleases: false`, and scripts/release-github-releases.mjs does the job instead. It is faithful to what the action produced — same tag, name, prerelease rule, and a direct port of the action's own getChangelogEntry for the body, which reproduces the real @objectstack/cli@17.0.0-rc.2 release body byte for byte (73,993 chars) — plus the three properties it lacked: - Bounded. An over-limit body is cut on a line boundary, any code fence the cut opened is closed so the notice renders as markdown rather than inside a code block, no surrogate pair is split, and both ends carry a link to the complete entry in CHANGELOG.md at the release commit. Cost is measured in UTF-16 code units, which is >= the code-point count for every string, so it can only over-estimate against whichever definition of "character" the API applies (the failing section is 342,893 characters but 359,636 UTF-8 bytes; the API quoted the former). - Idempotent. Looks the release up by tag and PATCHes when it exists, POSTs when it does not. rc.2 left ~69 of 70 releases created, so recovering over a partial set is the normal case, not the exception. - Isolated per package. The action ran the set through one Promise.all, so the first rejection abandoned the rest. This runs sequentially, collects failures and still exits non-zero, so one bad changelog can no longer cost @objectstack/spec its Release — and D4 its mount point. Turning createGithubReleases off also disables the action's per-tag `git push`, which lives in the same block. That is a bonus: scripts/release-publish.sh already pushes every tag in one atomic `git push origin --tags` precisely because those concurrent per-tag pushes raced GitHub's ref backend (#2191). Both publish paths are covered. The recovery step (#4901) now reports `npm-published` separately from `published` — the former means "packages went out and owe Releases", the latter "the docker job must build" — and emits its version unconditionally, since an npm repair whose image happens to exist still owes its Releases. release-spec-changes.sh takes that version as a fallback, so D4 mounts on the recovery path as well, which it never could before. `pnpm check:release-body` runs the script's --self-test in lint.yml: 49 assertions over the real code path, fed the REAL oversized section out of packages/spec/CHANGELOG.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
… nothing Empty frontmatter — the repo's sanctioned "this PR releases nothing" declaration, on par with the skip-changeset label (both are named in the Check Changeset gate). The PR changes only .github/workflows/, root scripts/ and one check: entry in the root (private) package.json, so nothing reaches a published package; a non-empty changeset would bump all 69 packages of the fixed group in lockstep and burn an extra rc for no shipped product code. The body records the one caveat that matters here: an empty changeset is the exact input #4898 showed can jam a release, which is now bounded rather than silent by the recovery step (#4899, made reachable by #4901) — and this PR is what extends the GitHub Releases and the ADR-0087 D4 spec-changes.json attachment onto that recovery path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
…releases nothing" This reverts 1f50271, keeping the `skip-changeset` label as this PR's only "releases nothing" declaration. The empty changeset was redundant with the label — Check Changeset exempts a labelled PR at the job level, and the earlier red run predated the label (the PR was created at 15:54:54, the label applied at ~15:56, so that run's event payload carried no labels at all). Any subsequent synchronize event re-evaluates the job `if:` against current labels. Redundancy is not free when the redundant copy is a known-dangerous shape. An empty changeset is exactly the input #4898 showed can jam a release: changesets/action reaches its publish branch only with ZERO pending changesets, and an empty one still counts as pending. The argument that this is now bounded rests on the recovery step (#4899/#4901) — which is a path THIS PR modifies. A PR whose whole purpose is repairing the release machinery should not plant a known-hazardous input and then lean on the very mechanism it is changing to catch it. One declaration, via the label, and no hazard. The gate text that recommends an empty changeset as the way out is tracked separately as #5292. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
os-zhuang
marked this pull request as ready for review
August 4, 2026 16:05
os-zhuang
enabled auto-merge
August 4, 2026 16:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4900
维护者裁定的方向 1(自建 Release 并截断)。
问题
changesets/action的createGithubReleases把每个包的 changelog 段落原样作为 Release body 提交。@objectstack/spec在一个 v17 RC 上的段落是 342,893 字符,上限 125,000,于是 POST 422。关键在于这个 throw 发生在runPublish内部 ——changeset publish已经完全成功,但 action 还没来得及setOutput('published')。所以步骤标红、published停在 false、dockerjob 被跳过:npm 上有包,ghcr 上没镜像。段落只会变大,所以窗口内每次发布都在同一处失败。
实测到的、比 issue 描述更严重的一点
对着线上 API 逐个 tag 探过:
@objectstack/spec@16.0.0spec-changes.json✅@objectstack/spec@16.1.0spec-changes.json✅@objectstack/spec@17.0.0-rc.0@objectstack/spec@17.0.0-rc.1@objectstack/spec@17.0.0-rc.2也就是说:ADR-0087 D4 的
spec-changes.json在整个 v17 RC 窗口里一直是没挂上的,不只是 rc.2 那一次。gh release upload需要一个已存在的 Release 作为落点,spec 的 Release 从 rc.0 起就没建成过。这条也正是方向 3(直接关掉createGithubReleases)不可行的原因。改法
createGithubReleases: false,改由scripts/release-github-releases.mjs建 Release。对 action 行为保持忠实:同样的 tag(
包名@版本)、同样的 release name、同样的prerelease判据;body 提取是 action 自己getChangelogEntry的逐行移植(含它跳过代码围栏的逻辑 —— 本仓 changeset 正文里真的有## FROM → TO这种二级标题)。验证方式是拿线上真实的@objectstack/cli@17.0.0-rc.2release body 逐字节比对,73,993 字符完全一致。在此之上补了它缺的三条性质:
17.0.0-rc.2→#1700-rc2,与github-slugger对过)。计量用 UTF-16 code unit,它对任何字符串都 ≥ code point 数,所以无论 API 把"字符"理解成哪一种都只会高估、不会低估(那段是 342,893 字符但 359,636 UTF-8 字节,API 报的是前者)。
already_exists。Promise.all,第一个 reject 就把其余的丢掉了。现在顺序执行、收集失败、最后仍非零退出,所以某个包的坏 changelog 不会再连累@objectstack/spec拿不到 Release(也就是 D4 拿不到落点)。顺序执行同时避开 chore: version packages #2191 那类并发 ref 请求打架的老问题。顺带解掉 #2191 的成因
关掉
createGithubReleases同时也关掉了 action 自己的逐个 taggit push—— 那些调用就在runPublish里同一个if (createGithubReleases)块内。这是收益不是损失:scripts/release-publish.sh早就用一次原子git push origin --tags抢在它前面,正是因为那批并发 push 会 race GitHub 的 ref 后端。现在那个 workaround 的成因本身没了(脚本保留,它仍是 tag 真正的推送方)。D4 的挂载点怎么保住的
Attach spec-changes.json之前,顺序是 load-bearing 的,注释里写明了。release-spec-changes.sh原来只从publishedPackages取版本;新增RELEASE_VERSION兜底,于是恢复路径(ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898) #4899/ci(release): 让发布完整性守卫真的能触发,并把镜像也纳入它守的不变量 (#4900) #4901 那条)也能挂上 D4,这是以前做不到的(旧代码里那句 warning 明说"这条路径不产生 Release 和 D4 附件",现已改写)。为此把恢复步骤的输出拆成两个语义:
published= "docker job 必须构建"(原义,未动,docker 的 gate 不变);npm-published= "这里确实发了包,因此欠 Release" —— 新增,Release/D4 两步用它做 gate;version改为无条件输出。原来它只在"镜像缺失"分支里才写,于是"npm 补发了、但镜像恰好存在"这条路径上版本号会丢。两个 recovery 缺陷
派单里点名的那两条(隐式
success()导致步骤被 skip、契约从"缺失就补发"改成守"npm 有包且有镜像"这个不变量)已经由 #4901 落在 main 上了(commitdb82f2e,本单派发前合入)。本 PR 没有重复修,只在其之上做了上面那两处扩展(npm-published与无条件version)。新步骤自身按同样理由带!cancelled()。验证
新增
pnpm check:release-body(= 脚本的--self-test),已接进lint.yml。49 条断言,走真实代码路径:覆盖:真实超限段落截断后 ≤ 125,000 且含锚点链接 / 短段落逐字节原样通过 / 围栏闭合 / 不切代理对 / 锚点 slug / 围栏内的
##不误判段落边界 / 两条输入路径的目标解析 / 缺条目时响亮失败 / 每个包都建 / 已存在则 PATCH 不 POST / 重跑零 POST / 单包失败不连累其余(并断言 spec 仍拿到 Release)。另外两项离线端到端验证:
main()(本地 http server,按 GitHub 的规则真的在 125,000 处返回 422):4 个包 → 3 created / 1 updated / 0 failed,spec 的 POST body 123,954 字符通过;请求序列确认了 by-tag 查询、%2F编码、PATCH by id、target_commitish。--dry-run:69 个包全部成功规划、0 失败,其中只有 spec 一个被截断;其余每个包的 body 长度与线上已发布的 release 逐个吻合(cli 73,993、runtime 44,532、types 6,433、verify 10,426、trigger-schedule 3,650 —— 与 API 返回值一致)。其他门禁:
pnpm lint干净、pnpm check:nul-bytesOK、pnpm check:node-versionOK、pnpm check:type-check-coverageOK、两个 workflow YAML 均可解析且if:/outputs 连线已逐条打印核对。证据边界(明说)
if:的隐式success()行为:依据 GitHub 文档中"未包含状态检查函数时自动套用success()"这一条,以及 ci(release): 让发布完整性守卫真的能触发,并把镜像也纳入它守的不变量 (#4900) #4901 已在真实 run 上确认过的同一现象;未用act或真实 run 验证本 PR 新增的两个if:。createGithubReleases: false的实际生效、以及"关掉它同时关掉逐个 tag push",是读changesets/action@v1的src/run.ts/src/index.ts源码得出的(getBooleanInput、if (createGithubReleases)块内含git.pushTag),未在真实发布中跑过。关于 changeset
按仓库惯例用
skip-changeset标签,不带 changeset:本 PR 只动 CI workflow 与根scripts/,不向任何 npm 包发货。同类的 #4899、#4901 都是这么走的;而放一个空 changeset 恰好会重建 #4898 那个卡死发布的条件 —— #4899 的正文对此有明确说明。(此处与派单里"写 changeset"的指示不同,理由如上,请维护者确认。)未改动
packages/*/CHANGELOG.md(方向 2 已否决)、content/docs/releases/,以及派单列明的禁触目录。🤖 Generated with Claude Code
https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
Generated by Claude Code