Skip to content

ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898) - #4899

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

ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898)#4899
xuyushun441-sys merged 1 commit into
mainfrom
claude/ci-errors-i1t8fi

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4898

17.0.0-rc.2 的版本号已经在 main 上(3cfd9f0),npm 上没有这个包。本该发布它的那次 Release run 30822140425 报的是 success:

Create Release Pull Request or Publish to npm ... 0s
All changesets are empty; not creating PR

changesets/action 只在零 pending changeset 时才进入 publish 分支。空 changeset 仍然计入 pending,于是「只剩空 changeset」的 main 两条路都不走 —— 不产生版本 PR,也不发布,published=false,整个 run 报绿。docker job 按 if: published == 'true' 一起 skipped,所以运行时镜像也没发,ghcr 最新仍是 17.0.0-rc.1

当时 main 上的 pending 集合正好是两个空 frontmatter 的 changeset(#4893 的和 #4896 的),任一个单独存在都足以触发

1. 解堵:删掉这两个空 changeset

空 frontmatter 的 changeset releases 为空,对任何包的 CHANGELOG 都不产生条目 —— 删掉不丢任何信息。它唯一的作用是满足 Check Changeset,而那两个 PR 几小时前就已经合并、闸门早已通过。

pending 集合清空后,下一次 main push 走 action 的正常 publish 分支,GitHub Releases、ADR-0087 D4 的 spec-changes 附件、npm、Docker 全都按原样产出。

所以本 PR 自己不带 changeset,改用 skip-changeset 标签。 如果这里再加一个空的,就会原样重建正在被移除的那个条件,并把这轮发布逼进下面那条降级路径。

2. 防复发:补一次幂等 publish + 漏发断言

在 changesets 步骤之后加一步,判据只取唯一真正要紧的事实 —— 仓库里的版本不在注册表上:

- name: Publish a release the Changesets action left behind
  id: recover-publish
  if: steps.changesets.outputs.published != 'true'

changeset publish 会跳过所有已发布的版本,所以它在正常路径上是 no-op(那时 main 的版本就是最后发布的那个),只在这条异常路径上才真正动作。它不会静默成功:补发之后版本若仍不在 npm 上,job 直接 ::error:: 退 1 —— 这正是今天缺的那个信号。

job 的 published / cli-version 两个 output 现在接受任一条 publish 路径,所以被救回来的发布仍然会发 Docker 镜像,而不是掉进同一个洞。

降级路径无法创建 GitHub Releases 和 spec-changes 附件(那些只存在于 action 自己的 publish 路径内),所以它用一条 warning 明说,而不是把这个缺口留给以后去发现。

验证

  • 两个 YAML 都能被 parser 解析;两个 job output 表达式在「action 发布」「fallback 发布」「都没发布」三种状态下取值都对。
  • 新步骤的 shell 通过 bash -n
  • 三条分支都用打桩的 npm/pnpm/git 实跑过:
    • 版本已在 npm 上 → 打印 already on npm,exit 0,不调用 publish;
    • 版本缺失 → 补发,GITHUB_OUTPUT 写入 published=true version=17.0.0-rc.2;
    • 版本缺失且补发后仍缺失 → ::error:: + exit 1。
  • packages/cli/package.json 路径核对过:@objectstack/cli @ 17.0.0-rc.2

顺带说明

同一次 run 里 hotcrm 那段 TS 报错与本问题无关,也不是没发布的原因 —— release.yml 在 pre 模式下刻意把 downstream smoke 降级为「报告但不阻塞」(#3600),该步骤 conclusion 是 success。它留下的义务是:changeset pre exit 之前必须发一个迁移过的 hotcrm 并上抬 HOTCRM_REF,否则闸门自动重新武装。

只动 CI workflow 与 changeset 文件,不涉及任何运行时代码。

🤖 Generated with Claude Code

https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf


Generated by Claude Code

…ing a release (#4898)

17.0.0-rc.2 is versioned in main (3cfd9f0) and absent from npm. The Release run
that should have published it reported success:

  Create Release Pull Request or Publish to npm ... 0s
  All changesets are empty; not creating PR

changesets/action reaches its publish branch only when there are ZERO pending
changesets. An EMPTY changeset still counts as pending, so a main carrying only
empty ones takes neither path — no version PR, no publish, published=false, and
a green run. `docker` skipped with it, so no runtime image shipped either.

Two pending changesets were on main, both empty-frontmatter: #4893's and
#4896's. Either one alone is enough. This is not an exotic state — `Check
Changeset` sanctions the empty changeset as the "this PR releases nothing"
declaration, so any docs/ci PR landing during the version-PR window produces it.

Unstick: delete both. An empty-frontmatter changeset declares no releases, so it
contributes no entry to any package's CHANGELOG — deleting it discards nothing.
Its only job was to satisfy `Check Changeset` for PRs that merged hours ago.
With the pending set empty, the next push to main takes the action's normal
publish path, which ships the GitHub Releases and the ADR-0087 D4 spec-changes
attachment along with npm and Docker.

This PR therefore adds no changeset of its own — it carries the `skip-changeset`
label instead. Adding an empty one would re-create the exact condition being
removed and force the release down the degraded fallback path below.

Prevent the silent recurrence: a recovery step after the changesets action,
gated on the only fact that matters — the version in the repo is not on the
registry. `changeset publish` skips versions already published, so it is a
no-op on the normal path (main's version IS the last released one) and a repair
on this one. It never succeeds silently: if the version is still missing after
publishing, the job fails. The job's `published` / `cli-version` outputs now
accept either publish path, so a rescued release still ships its Docker image
instead of losing it to the same hole.

The fallback cannot create GitHub Releases or the spec-changes attachment —
those live inside the action's own publish path — so it says so in a warning
rather than leaving the gap to be discovered later.

Verified: YAML parses, both job outputs resolve on each path, the step's shell
passes `bash -n`, and all three branches were exercised against stubbed
npm/pnpm/git — already-published (no-op, exit 0), missing (publishes, sets
published/version), and missing-after-publish (::error::, exit 1).

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:34pm

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 size/m documentation Improvements or additions to documentation labels Aug 3, 2026
@os-zhuang os-zhuang removed the documentation Improvements or additions to documentation label Aug 3, 2026 — with Claude
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 3, 2026
@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 14:37
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 89d2a4e Aug 3, 2026
29 of 30 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/ci-errors-i1t8fi branch August 3, 2026 14:41
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 4, 2026
…t guard the image too (objectstack-ai#4900) (objectstack-ai#4901)

Two defects in the guard objectstack-ai#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 (objectstack-ai#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.


Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf

Co-authored-by: Claude <noreply@anthropic.com>
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 documentation Improvements or additions to documentation size/m 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.

空 changeset 会静默卡死已 version 的发布:Release run 全绿,但 npm 和 Docker 什么都没发(17.0.0-rc.2 现在就卡着)

3 participants