fix(release): retry the post-publish npm check, and verify all 69 packages instead of 1 - #15331
fix(release): retry the post-publish npm check, and verify all 69 packages instead of 1#15331hotlong wants to merge 2 commits into
Conversation
…ages The last step of the publish job verified the release with one `npm view` of @objectstack/cli. On 17.3.0 it exited 1 on a release that had succeeded completely. It raced the registry: npm committed cli@17.3.0 at 10:53:25.108 and the check read at 10:53:32, seven seconds later, and got an absence. The write path and the CDN-fronted read path are eventually consistent. And it looked at 1 package of 69, so a package that genuinely failed to publish is the one case it could not see — and the false red on cli would have masked it. @objectstack/runtime did not commit until 11:00:32. scripts/release-verify-npm.mjs replaces it: bounded backoff over the whole derived publishable set, with the absent packages named on failure. It still fails closed — the retry absorbs latency, never absence — and an empty target set is refused rather than passed vacuously. Publish gating is untouched: everything up to the step body and everything from "Create GitHub Releases" to EOF is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The append was `import('node:fs').then(...)` — a floating promise, discarded by
the caller's `process.exit(await main())`. It wrote the summary on neither path
while reading, in review, exactly like one that did.
Battery 9 pins it the only way this class can be pinned: append, then read the
file back with no await in between. A deferred write fails that case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
复核:PASS
这张卡我只有一件事必须自己验:发布门控有没有被动因为这是仓库里唯一发布 npm 的文件。已核,没动:
fail closed 成立,逐字: if ! RELEASE_VERSION="$VERSION" node scripts/release-verify-npm.mjs; then
exit 1
fi
echo "published=true" >> "$GITHUB_OUTPUT"新代码里那段注释也把边界写死了 ——「 空目标集的拒绝:已核,而且是这次交付里最值钱的一处if (!Array.isArray(targets) || targets.length === 0) {
problems.push(
'the publishable-package derivation produced NO targets. Verifying zero packages would report '
+ '"every published package is on npm" having read nothing — refusing to pass vacuously.',
);⭐ 这正是一小时前我在 #15261 上抓到的同一类口子(账本清零后 自测台账本身也按 #13489 的正确形状做的 —— 钉的是 battery 的名字而不是数字,并且台账自身的大小也被钉住(
15 分钟预算是测出来的,不是拍的取自 17.3.0 那次发布自己的 npm 三处工程纪律值得点名
一处汇报遗漏(不影响判定)报告的文件清单没列 落地⛔ 我不武装 auto-merge,这条不改。
Generated by Claude Code |
Fixes #15321
The last step of the
publishjob verified the release with onenpm viewof@objectstack/cli. On the 17.3.0 release (run 8330, job 100996109699) it exited1 on a release that had succeeded completely. Two defects, both fixed here.
① It raced the registry. npm committed
@objectstack/cli@17.3.0at10:53:25.108Z and the check read at 10:53:32 — seven seconds later — and got an
absence. npm's write path and its CDN-fronted read path are eventually
consistent, and one shot immediately after a 69-package burst bets the release
on how fast reads settle.
② It looked at 1 package of 69. Had a package genuinely failed to publish,
the step could not have seen it — and the false red on
cliwould have maskedit. Measured on the same release,
@objectstack/runtimedid not commit until11:00:32, so between 10:53 and 11:00 the fixed group really was partially
readable and nothing was looking.
What changed
.github/workflows/release.yml— thepublishjob's last step now callsnode scripts/release-verify-npm.mjsin place of the inlinenpm view. Samestep, same
id: publish, samepublished=trueoutput, sameif ! …; then exit 1; fishape.$VERSIONis handed over asRELEASE_VERSION, the spelling thetwo sibling steps already use.
⛔ Nothing about publish GATING is touched.
environment: release, thepublish-pendingpredicate and its load-bearingsuccess() && (...)parenthesisation,
force's dispatch-only semantics, theon:block and thewhole
release-integrityjob are byte-identical. This change is entirely aboutwhat happens after a publish, never whether one runs.
git diffon thatfile is one step body.
scripts/release-verify-npm.mjs— new. Bounded backoff, whole derived packageset, failure names what is absent. It also writes a short receipt to
$GITHUB_STEP_SUMMARYsaying what was checked and how long the read path tookto settle, so the next person to tune the budget has measurements rather than a
docblock.
.github/workflows/lint.yml— runs the new script's--self-test, whichcheck:self-test-wiredrequires and which is the only instrument on this logic.⛔ It still fails CLOSED
The retry absorbs latency, never absence. A package still missing when the
budget is spent exits non-zero, named; so does a registry that could not be read
at all ("we could not tell" is not "it is there"). There is no
|| true, nodowngrade to a warning, and no path that reports success without having read
every target. The vacuity direction is guarded too: an empty target set is a
hard failure, not a green run of zero checks.
Where the retry budget comes from
Not a guess — npm's own
timefield on that release. The spread from the firstread (
cli, 10:53:25, which is when verification starts) to the last commit(
runtime, 11:00:32) is 7m07s. The budget is 15 minutes: a little overtwice the observed spread, because burst latency scales with whatever else the
registry is absorbing. Backoff is 5s doubling to a 60s cap, with the final wait
truncated so the schedule lands on the budget exactly. A self-test case pins
RETRY_BUDGET_MS > 7m07s, so shrinking the budget below the measurement reds.Cost in the other direction: a genuinely broken publish is reported 15 minutes
later — paid once, by a job that has already spent longer than that building.
Why the package set is derived from the workspace
Every non-private workspace package, each at the version its own manifest
declares — 69 today, and
--dry-runprints them. Membership comes fromlistWorkspacePackagesinscripts/release-github-releases.mjs, the releaselane's existing answer to "which packages are publishable", itself built on
scripts/workspace-enumerator.mjs— this repo's one parse ofpnpm-workspace.yaml. Reusing it rather than writing a fourth private copy ofmanifest.private !== trueis the point: two release-lane scripts disagreeingabout which packages a release contains is its own defect.
Why this source rather than
changeset publish's stdout — the direction eachfails in. Parsing the publisher's own report is self-referential: if the
publish died early, printed nothing, or changed its output format, the parse
yields an empty set and the verification passes having checked nothing. That
is the silent-success direction, and it is the class of bug this card is about.
The workspace derivation is independent of the publish's belief — it is the set
that ought to be on npm, computed from the tree the guard step already proved
matches
github.sha.What happens if it misses a package. It cannot under-select against
changeset publish, whose own publishable set is the same workspace filter: apackage outside the workspace is not something this repo can publish at all. The
one way it could over-select is a future non-empty
.changeset/config.jsonignore(empty today) — and that direction is loud: the run goes red namingthat package, noticed within one release, rather than passing in silence. Two
existing gates keep the derivation honest and neither is this one:
check-changeset-fixed.mjs(run by this same job, before the build) assertsevery non-private workspace package is in the Changesets
fixedgroup, and apackage this repo does not publish is marked
private: truein its ownmanifest, which is exactly the field the filter reads.
The version is read per package rather than assumed from one release number.
The
fixedgroup makes those equal today; this is a derivation that does notdepend on it.
The failure message
The old one named
@objectstack/clion a release wherecliwas the onepackage definitely published, so whoever picked it up went and looked at a
healthy package. The new one names only what is absent, says how many of how
many, distinguishes "not on the registry" from "registry unreadable", and points
at the repair channel that actually exists:
Reproduced, then shown green
The regression needs no npm. The self-test drives the real code with a stub
registry whose read path settles — a package that is committed but answers
absent for its first N reads — and expresses the old shape as parameters of
the same function (
targets: [cli],budgetMs: 0), so the old behaviour ispinned by this code rather than by a second implementation nothing holds to it.
cli, one target, no retry@objectstack/cli, the package that was fine; registry read exactly onceruntimenever lands, one target, no retry@objectstack/runtime@objectstack/runtimeand nothing else52 cases across 9 batteries. The self-test carries the #13489 registered-case
floor: a battery that stops running reds by set difference, and the roster's own
size is pinned — a verifier whose own cases can silently stop running is the same
class of bug as the one being fixed.
Verification
At
1ba59a8ed. Every exit code captured before any pipe(
cmd > log 2>&1; EXIT=$?), and every verdict quoted from the line the toolitself printed.
node scripts/release-verify-npm.mjs --self-test→ exit 0 —OK release-verify-npm self-test: 52 cases pass across 9 batteriesnode scripts/release-verify-npm.mjs --dry-run→ exit 0 —69 package(s) would be verified on npm, all at 17.3.0registry.npmjs.org:@objectstack/cli@17.3.0→true,@objectstack/cli@99.99.99→false. The probe is exercised against the realregistry, not only against the stub.
pnpm exec eslint --no-inline-config scripts/release-verify-npm.mjs→exit 0, 0 errors 0 warnings
Four ablations, each run from the committed state, each mutation proven on
disk by counting the removed anchor and the injected marker, each restore
proven by
git hash-objectmatching the HEAD blob withgit diff HEADempty:battery "3. The masked defect…" DID NOT RUN — 0 cases registered, 6 pinnedRETRY_BUDGET_MS→ 0NEW SHAPE absorbs the cold readnowrounds=1 waits=0clionly--dry-run→1 package(s) would be verified69 package(s) would be verifiedENOENT … summary.mdon the immediate read-backThe battery-floor ablation is the one that matters for the #13799 recipe: it
leaves the total unchanged while battery 3 registers 0, which is exactly the
regression a pinned total cannot see and a registered-name floor can.
Two ablation attempts were discarded as void readings, not re-read until
they went green — both because the proof that the mutation landed did not hold,
which means the ablation did not run:
handed to
grep -c -F, which treats a newline as a pattern separator andcounted 59 matching lines. Redone with a single-line anchor.
perl -0piwhose escaping failed tocompile, so the file was untouched — the anchor count stayed 1 and the marker
count 0, and the run was refused rather than read. Redone with a node edit.
Recorded because a silent re-run until something lands is the same defect one
layer up.
Gates.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 45 commands at1ba59a8ed; all 45 run. 43 exit 0. Thetwo non-zero are both self-classified as non-findings by the gates themselves
and are reported as NOT MEASURED, never as passes:
check-required-contexts.mjs --verify-required-set→ exit 2,required-set sweep: NOT VERIFIED — … HTTP 401. The gate's own words: "NOT VERIFIED is not apass and not a failure of the tree; exit 2 classifies the ENVIRONMENT." This
container has no GitHub token. Retried under
NODE_OPTIONS=--use-env-proxyasthe gate suggests: still exit 2 (403). Unmet prerequisite: a token.
pnpm check:required-contexts(the tree half) exits 0.pnpm check:type-check-debt→ exit 3,PREREQUISITE NOT MET — 33 workspace dependenc(ies) … have no built type entry point on disk. Exit 3 is thatgate's distinct "nothing was measured" code. Unmet prerequisite: the built
dependency closure. This diff contains no TypeScript, so no package's
type-check surface moves.
pnpm check:type-check-coverageexits 0.Lint narrowing, declared.
pnpm lintscans the whole repo and CI runs itonce regardless; the local run is narrowed to the changed file, and the
narrowing is measured rather than assumed. (i) The two
.ymlfiles are outsideeslint's population; the one
.mjsfile is inside it — eslint linted it ratherthan reporting it ignored. (ii)
--format jsonreports exactly 1 filelinted, 0 errors, 0 warnings. (iii) No
parserOptions.projectand noprojectServiceappears anywhere ineslint.config.mjs(line 328 states theabsence outright), so type-aware linting is off and every file's verdict is a
function of that file plus the config — neither of which this diff moves for any
untouched file. The narrowing therefore excludes nothing.
The out-of-scope claim is machine-checked, not asserted. In
release.yml, everything from the top of the file through the line before theedited step body is byte-identical to
eb40a7210, and everything from- name: Create GitHub Releasesto EOF (50 lines) is byte-identical. The diffremoves exactly three lines, all three inside the verification block. The
success() && (...),release-integrity:,workflow_dispatch:andrefresh_version_proccurrence counts are unchanged;environment: releaseandpublish-pendingeach gain exactly one occurrence, both on one line — the newcomment saying the gating is not this check's business.
No changeset —
skip-changeset.github/workflows/**andscripts/**publish nothing from any package, sothis is the
skip-changesetlabel case, not an empty changeset (rejected by#5471, and #4898 is that failure silently blocking a publish).
⛔ No auto-merge is armed.
release.ymlis the one file in the repository thatpublishes; a human reviews it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Generated by Claude Code
Generated by Claude Code