Skip to content

ci(release): 让发布完整性守卫真的能触发,并把镜像也纳入它守的不变量 (#4900) - #4901

Merged
os-zhuang merged 1 commit into
mainfrom
claude/ci-errors-i1t8fi
Aug 3, 2026
Merged

ci(release): 让发布完整性守卫真的能触发,并把镜像也纳入它守的不变量 (#4900)#4901
os-zhuang merged 1 commit into
mainfrom
claude/ci-errors-i1t8fi

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

#4899 引入的那个守卫的两处缺陷 —— 紧接着的下一次发布(89d2a4e)就把它们全暴露了。

那一轮:npm 成功发布了全部 17.0.0-rc.2 并推了 tag,然后创建 @objectstack/spec 的 GitHub Release 时撞上 API 的 125k body 上限失败(该版本的 changelog 段落 342,911 字符,详见 #4900)。步骤标红 → published 没置真 → 运行时镜像被静默丢掉

缺陷 1:守卫根本触发不了

GitHub 会给不带状态函数if: 隐式包一层 success()。所以

if: steps.changesets.outputs.published != 'true'

实际是 success() && (...) —— changesets 步骤一失败它就被 skipped,而那正是它存在的两个理由之一。改为 !cancelled() && ...

缺陷 2:它守的契约错了

原来写的是「缺失就补发」。这覆盖不了「发布成功但在上报前挂了」:rc.2 当时已经在 npm 上,即使它执行了也是 no-op,镜像照样丢。

现在它守的是发布真正欠下的不变量 —— 本仓库的当前版本必须在 npm 上,且必须有对应的运行时镜像 —— 并把后半句通过 job output 上报,让 docker job 去建。

docker job 同样加 !cancelled()

默认隐式 success() 下,上游 job 一失败依赖 job 就被跳过 —— 于是镜像丢在了一个发生在包已经公开之后的故障上。真正的闸门是 published 输出,不是 release job 的退出码。run 仍然会红(GitHub Release 确实失败了),但镜像不再陪葬。

一个刻意的取舍

ghcr 探测失败按「镜像缺失」处理:多建一次镜像只是几分钟,而错误地跳过会留下一个「npm 上有包、没有镜像、且没有任何信号」的发布 —— 正是今天这个坑。

验证

四条路径都用打桩的 npm/pnpm/git/curl 实跑过:

场景 结果
npm 有 + 镜像有(正常路径) 完全 no-op,不写任何 output
npm 有 + 镜像缺(今天这次) 请求 docker,不重复发包
npm 缺(#4898 场景) 补发 → 再请求 docker
ghcr 探测失败 按缺失处理

ghcr 探测本身是对线上真实注册表验证的:能正常换到匿名 pull token(长度 68),并正确报告 17.0.0-rc.1 存在、17.0.0-rc.2 不存在。

YAML 解析通过,新步骤 shell 过 bash -n,两个 job output 表达式取值已核对。

关联

只动 release.yml,不涉及任何运行时代码。

🤖 Generated with Claude Code

https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf


Generated by Claude Code

…t guard the image too (#4900)

Two defects in the guard #4899 added, both exposed by the very next release
(89d2a4e). npm published all of 17.0.0-rc.2 and pushed its tags, then creating
the @objectstack/spec GitHub Release failed on the API's 125k body limit — the
spec changelog section for that version is 342,911 characters (#4900). The step
went red, `published` stayed false, and the runtime image was silently lost.

1. The guard could not fire. GitHub wraps an `if:` naming no status function in
   an implicit success(), so `if: steps.changesets.outputs.published != 'true'`
   was really `success() && …` — skipped for any changesets-step failure, which
   is one of the two cases it exists for. Now `!cancelled() && …`.

2. Its contract was wrong. "Publish whatever is missing" does not cover a
   release that published and then died before reporting it: rc.2 was already on
   npm, so a publish-only step would have no-opped and lost the image anyway. It
   now guards the invariant the release actually owes — this repo's version must
   be on npm AND must have a matching runtime image — and reports the second
   half through the job outputs so `docker` builds it.

`docker` gains the same `!cancelled()` treatment. A dependent job under the
default implicit success() is skipped for any upstream failure, so the image was
lost to a fault that happened after the packages were already public. The
`published` output is the real gate; the release job's exit status is not, and
the run stays red either way because the GitHub Release genuinely failed.

A failed ghcr probe counts as MISSING on purpose: a redundant rebuild costs a
few minutes, a wrongly-skipped one leaves a published npm version with no image
and nothing to say so.

Verified against stubbed npm/pnpm/git/curl — npm present + image present (full
no-op, no outputs), npm present + image missing (requests docker, does NOT
republish), npm missing (publishes, then requests docker), and ghcr unreachable
(treated as missing). The ghcr probe itself was checked against the live
registry: it resolves an anonymous pull token and correctly reports 17.0.0-rc.1
present and 17.0.0-rc.2 absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf
@vercel

vercel Bot commented Aug 3, 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 3, 2026 2:58pm

Request Review

@os-zhuang os-zhuang added ci/cd tooling skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Aug 3, 2026 — with Claude
@github-actions github-actions Bot added the size/s label Aug 3, 2026
@os-zhuang os-zhuang added tooling and removed size/s tooling labels Aug 3, 2026 — with Claude
@github-actions github-actions Bot added the size/s label Aug 3, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 3, 2026 15:07
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit db82f2e Aug 3, 2026
35 of 36 checks passed
@os-zhuang
os-zhuang deleted the claude/ci-errors-i1t8fi branch August 3, 2026 15:11
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
… 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
os-zhuang pushed a commit that referenced this pull request 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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…bjectstack-ai#4900) (objectstack-ai#5290)

* ci(release): build GitHub Releases ourselves, with bodies that fit the 125k limit (objectstack-ai#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 (objectstack-ai#2191).

Both publish paths are covered. The recovery step (objectstack-ai#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

* chore(changeset): declare that objectstack-ai#4900's release-machinery fix releases 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 objectstack-ai#4898 showed can jam a release, which is now bounded rather than
silent by the recovery step (objectstack-ai#4899, made reachable by objectstack-ai#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

* Revert "chore(changeset): declare that objectstack-ai#4900's release-machinery fix 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 objectstack-ai#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 (objectstack-ai#4899/objectstack-ai#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 objectstack-ai#5292.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants