Skip to content

ci(release): 自建 GitHub Release,body 截断到 125k 以内,并把 ADR-0087 D4 重新挂上 (#4900) - #5290

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-4900-release-body-limit
Aug 4, 2026
Merged

ci(release): 自建 GitHub Release,body 截断到 125k 以内,并把 ADR-0087 D4 重新挂上 (#4900)#5290
os-zhuang merged 3 commits into
mainfrom
claude/issue-4900-release-body-limit

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4900

维护者裁定的方向 1(自建 Release 并截断)

问题

changesets/actioncreateGithubReleases 把每个包的 changelog 段落原样作为 Release body 提交。@objectstack/spec 在一个 v17 RC 上的段落是 342,893 字符,上限 125,000,于是 POST 422。关键在于这个 throw 发生在 runPublish 内部 —— changeset publish 已经完全成功,但 action 还没来得及 setOutput('published')。所以步骤标红、published 停在 false、docker job 被跳过:npm 上有包,ghcr 上没镜像。

段落只会变大,所以窗口内每次发布都在同一处失败。

实测到的、比 issue 描述更严重的一点

对着线上 API 逐个 tag 探过:

tag Release body 字符 D4 附件
@objectstack/spec@16.0.0 200 62,886 spec-changes.json
@objectstack/spec@16.1.0 200 1,523 spec-changes.json
@objectstack/spec@17.0.0-rc.0 404
@objectstack/spec@17.0.0-rc.1 404
@objectstack/spec@17.0.0-rc.2 404

也就是说: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.2 release body 逐字节比对,73,993 字符完全一致

在此之上补了它缺的三条性质:

  1. 有界。超限时按行边界截断;截断处若把代码围栏切开,会补一个闭合围栏 —— 否则说明文字和 CHANGELOG 链接会被吞进代码块里,也就是截断唯一欠读者的东西恰好渲染不出来;不切断代理对;首尾各放一次指向完整条目的链接(锚点用 GitHub 的 heading slug,17.0.0-rc.2#1700-rc2,与 github-slugger 对过)。
    计量用 UTF-16 code unit,它对任何字符串都 ≥ code point 数,所以无论 API 把"字符"理解成哪一种都只会高估、不会低估(那段是 342,893 字符但 359,636 UTF-8 字节,API 报的是前者)。
  2. 幂等。先按 tag 查,存在就 PATCH、不存在才 POST。rc.2 那次 70 个里已经建成了约 69 个,所以"在半成品集合上重跑"才是常态,不是例外 —— 重跑不会撞 already_exists
  3. 按包隔离。action 把整组丢进一个 Promise.all,第一个 reject 就把其余的丢掉了。现在顺序执行、收集失败、最后仍非零退出,所以某个包的坏 changelog 不会再连累 @objectstack/spec 拿不到 Release(也就是 D4 拿不到落点)。顺序执行同时避开 chore: version packages #2191 那类并发 ref 请求打架的老问题。

顺带解掉 #2191 的成因

关掉 createGithubReleases 同时也关掉了 action 自己的逐个 tag git push —— 那些调用就在 runPublish 里同一个 if (createGithubReleases) 块内。这是收益不是损失:scripts/release-publish.sh 早就用一次原子 git push origin --tags 抢在它前面,正是因为那批并发 push 会 race GitHub 的 ref 后端。现在那个 workaround 的成因本身没了(脚本保留,它仍是 tag 真正的推送方)。

D4 的挂载点怎么保住的

为此把恢复步骤的输出拆成两个语义:

  • published = "docker job 必须构建"(原义,未动,docker 的 gate 不变);
  • npm-published = "这里确实发了包,因此欠 Release" —— 新增,Release/D4 两步用它做 gate;
  • version 改为无条件输出。原来它只在"镜像缺失"分支里才写,于是"npm 补发了、但镜像恰好存在"这条路径上版本号会丢。

两个 recovery 缺陷

派单里点名的那两条(隐式 success() 导致步骤被 skip、契约从"缺失就补发"改成守"npm 有包且有镜像"这个不变量)已经由 #4901 落在 main 上了(commit db82f2e,本单派发前合入)。本 PR 没有重复修,只在其之上做了上面那两处扩展(npm-published 与无条件 version)。新步骤自身按同样理由带 !cancelled()

验证

新增 pnpm check:release-body(= 脚本的 --self-test),已接进 lint.yml49 条断言,走真实代码路径

✓ release-github-releases --self-test: 49 assertions
  (real packages/spec/CHANGELOG.md 17.0.0-rc.2 section = 342893 chars -> 123938, limit 125000)

覆盖:真实超限段落截断后 ≤ 125,000 且含锚点链接 / 短段落逐字节原样通过 / 围栏闭合 / 不切代理对 / 锚点 slug / 围栏内的 ## 不误判段落边界 / 两条输入路径的目标解析 / 缺条目时响亮失败 / 每个包都建 / 已存在则 PATCH 不 POST / 重跑零 POST / 单包失败不连累其余(并断言 spec 仍拿到 Release)。

另外两项离线端到端验证:

  • 对着桩 GitHub API 跑真实的 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-bytes OK、pnpm check:node-version OK、pnpm check:type-check-coverage OK、两个 workflow YAML 均可解析且 if:/outputs 连线已逐条打印核对。

证据边界(明说)

  • if: 的隐式 success() 行为:依据 GitHub 文档中"未包含状态检查函数时自动套用 success()"这一条,以及 ci(release): 让发布完整性守卫真的能触发,并把镜像也纳入它守的不变量 (#4900) #4901 已在真实 run 上确认过的同一现象;未用 act 或真实 run 验证本 PR 新增的两个 if:
  • createGithubReleases: false 的实际生效、以及"关掉它同时关掉逐个 tag push",是读 changesets/action@v1src/run.ts / src/index.ts 源码得出的(getBooleanInputif (createGithubReleases) 块内含 git.pushTag),未在真实发布中跑过
  • 因此整条链路真正的验证要等下一次真实发布;上面所有数值都来自离线 + 线上只读 API 探测。

关于 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

…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 4:03pm

Request Review

… 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
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 4, 2026
…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
os-zhuang marked this pull request as ready for review August 4, 2026 16:05
@os-zhuang
os-zhuang enabled auto-merge August 4, 2026 16:05
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 5993c5b Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4900-release-body-limit branch August 4, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

2 participants