Skip to content

fix: publish the version the workspace holds, not the changelog's - #1458

Merged
vivek7405 merged 3 commits into
mainfrom
fix/publish-npm-version-mismatch
Aug 21, 2026
Merged

fix: publish the version the workspace holds, not the changelog's#1458
vivek7405 merged 3 commits into
mainfrom
fix/publish-npm-version-mismatch

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Summary

npm publish --workspace=<pkg> ships the version in that workspace's package.json. scripts/publish-npm.js took its idempotency check from the changelog file's frontmatter instead. Those are two different numbers, and nothing noticed, because on the normal release path the bump and its generated changelog land in the same commit and always agree.

They diverge the moment an older changelog file is republished through the republish_paths dispatch input added in #1457. That happened for real while clearing the backlog: republishing changelog/core/0.7.52.md shipped 0.7.53 (the tree's version) under the log line published @webjsdev/core@0.7.52, then the next file in the batch died with E403 cannot publish over the previously published versions: 0.7.53, taking server@0.8.66 and cli@0.10.57 with it under set -e.

The misleading log line is the worst part: the run reported a successful publish of a version it had not published, so the mistake was invisible until the registry was checked by hand.

What changed

  • publish-npm.js resolves the workspace's real version through the same --workspace= lookup npm publish uses (rather than guessing a directory from the package name, which would have to special-case packages/editors/intellisense and packages/wrappers/*), and skips with a message naming both versions when the tree has moved past the changelog's.
  • The guard runs before the registry check. A version whose source is gone cannot be published whatever the registry says, and that ordering is also what keeps the new test offline.
  • Unparseable npm pkg get output fails open rather than blocking a release, since it is not evidence of a mismatch.
  • The success line reports the version actually shipped instead of the changelog's.
  • framework-dev.md documents the constraint, and points at publishing from a checkout of the original commit if a skipped version is genuinely needed.

Test plan

test/packaging/publish-npm-version-match.test.mjs, following the shape of its changelog-editor-packages.test.mjs sibling:

  • A changelog version the workspace has moved past is skipped (exit 0), with the tree's version named in the message
  • Counterfactual: a version matching the workspace does NOT take the skip path, so the guard is keyed on the mismatch rather than skipping everything
  • Counterfactual verified against the real source: reverting publish-npm.js to its pre-fix state at HEAD~1 fails the first test with expected a clean skip, got status 1, while the second stays green
  • The sibling changelog-editor-packages.test.mjs still passes 4/4, so the npm: false skip path is intact
  • Both tests are offline and need no registry

Docs surfaces

  • framework-dev.md: updated, the republish recovery section.
  • Docs site, README, scaffold, MCP, editor plugins: N/A because this is release tooling with no user-facing or agent-facing surface.
  • Bun parity: N/A because no runtime-sensitive source is touched.
  • Dogfood apps: N/A because nothing that affects what the apps serve is touched.

Note

core@0.7.52 stays unpublished and unpublishable, which this PR makes explicit rather than fixing. It is fully superseded by core@0.7.53, which is live, so npm shows 0.7.51 followed by 0.7.53 with no release tag for 0.7.52.

`npm publish --workspace=<pkg>` ships the version in the workspace's
package.json, while publish-npm.js took its idempotency check from the
changelog file's frontmatter. Those agree on the normal release path,
where the bump and its generated changelog land in one commit, so nothing
noticed they were two different numbers.

They diverge the moment an older changelog file is republished through
the republish_paths dispatch input. Republishing changelog/core/0.7.52.md
shipped 0.7.53 under the log line `published @webjsdev/core@0.7.52`, and
the next file in the batch then died with `E403 cannot publish over the
previously published versions: 0.7.53`, taking every remaining package
with it under set -e. The misleading log line is the reason this read as
a success while it happened.

So compare the two and skip when the tree has moved on, resolving the
workspace version through the same --workspace= lookup publish uses
rather than guessing a directory from the package name. The guard runs
before the registry check, since a version whose source is gone cannot be
published whatever the registry says, which also keeps the test offline.
The success line now reports the version actually shipped.
@vivek7405 vivek7405 self-assigned this Aug 21, 2026

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard itself is right: comparing against the same --workspace= resolution the publish uses is the correct source of truth, running it before the registry check keeps it offline, and the incident writeup in the comments will save whoever hits this next. Two things need tightening before this merges, both on the failure paths rather than the happy path.

First, the shape-drift handling is inconsistent in the dangerous direction. Unparseable npm pkg get output fails open (good), but parseable output with an unexpected shape falls into the mismatch branch and exits 0, which would silently skip every publish while the workflow stays green. That is the exact failure mode this PR exists to kill, reintroduced one level up. Inline suggestion on the assignment.

Second, the counterfactual test is not offline, despite the header saying both cases are. The spawned child escapes the deny-live-hosts preload (framework-dev.md documents this exact trap), and on a release branch the fallthrough is a real npm publish attempt. Inline suggestion pins the child to an unroutable registry.

The framework-dev.md escape hatch also oversells slightly; noted inline.

Comment thread scripts/publish-npm.js Outdated
Comment thread test/packaging/publish-npm-version-match.test.mjs Outdated
Comment thread framework-dev.md Outdated
Three review findings.

A parseable but unexpected `npm pkg get` shape put a non-string into
treeVersion, which then failed the !== comparison and exited 0 as a
mismatch skip. That would silently skip every publish in a batch while
the workflow stayed green, which is the same silent-wrong-outcome class
this branch exists to remove. Non-string values now fail open exactly as
unparseable output already did.

The counterfactual test was not offline despite the header saying so:
the deny-live-hosts preload does not reach a spawned child, so npm view
went to the live registry, and on a release branch (tree version not yet
published) the script fell through to a real npm publish with whatever
auth was ambient. The child is now pinned to an unroutable registry.
fetch-retries is 0 because npm's default of 2 retries with exponential
backoff turned connection-refused into a 72 second test.

The framework-dev.md escape hatch suggested publishing a superseded
version from a checkout of its original commit, which cannot work:
trusted publishing only exists inside a release.yml run, and that
commit's workflow expects the expired token. Says what it would really
take instead.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-read the whole thing at this head, with attention on the three fixes.

All three land. The shape-drift guard is right, and I checked the fail-open path holds for the awkward inputs too: JSON.parse("null") throws on the property read and lands in the catch, an array or empty object yields undefined and nulls out, so nothing but a real string reaches the comparison.

I also went after the offline claim rather than taking it, since a pinned registry is exactly the kind of thing that quietly does nothing. A scoped @webjsdev:registry or a publishConfig.registry would override npm_config_registry and send the publish to the real registry anyway. Neither exists: no repo .npmrc, no scoped config, and publishConfig on core/cli/server carries only access. Env also outranks project config in npm's hierarchy, so the pin holds in CI too. The claim is good.

Two things left, both small and neither blocking.

The one that matters slightly is the success log line, because the delta widened the case where it misreports. Inline.

The other is that the counterfactual proves less than it looks like it proves. Also inline, and arguably fine as is.

Comment thread scripts/publish-npm.js
// for. The guard above makes them equal, so this is belt and braces: the old
// line took its number from the changelog filename unconditionally, which is
// what let a publish of the wrong version read as a success in the log.
console.log(`[publish-npm] published ${pkgName}@${treeVersion ?? version} (${basename(file)})`);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delta made this reachable in a way it mostly was not before. treeVersion is now null on any drifted-but-parseable shape, not just on a parse failure, and in that case this falls back to version, the changelog's number, which is precisely the misleading value this line was changed to stop printing.

Narrow, and not wrong exactly, since when the lookup fails there is no better number to hand. But the PR's claim is that the line reports what shipped, and on the fail-open path it reports a guess. Worth either saying so in the line itself or dropping the fallback and printing the package name alone when the version is unknown:

Suggested change
console.log(`[publish-npm] published ${pkgName}@${treeVersion ?? version} (${basename(file)})`);
console.log(
`[publish-npm] published ${pkgName}@${treeVersion ?? `${version} (assumed: workspace lookup failed)`} (${basename(file)})`,
);

Comment on lines +97 to +101
assert.doesNotMatch(
r.stdout,
/can no longer be published from this tree/,
`the guard must not fire when the versions agree, got: ${r.stdout}`,
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts only that a string is absent, so it passes for reasons that have nothing to do with the guard: a crash before the guard, a script that exits early, a runFor that returns nothing. Its job is to stop the first test being vacuous, and a negative assertion does that only weakly.

Since the registry is pinned unroutable, the matching-version case now always runs past the guard into the view and publish attempts, which fail predictably. That gives you something positive to assert on instead, e.g. that the run reached the publish step (non-zero exit with the npm publish failed line on stderr), which would actually pin down that control flow got past the guard rather than merely that one sentence never printed.

Not blocking, and the positive test above carries most of the weight. Flagging because a counterfactual that cannot fail for the right reason tends to rot quietly.

@vivek7405
vivek7405 merged commit e86d3d9 into main Aug 21, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/publish-npm-version-mismatch branch August 21, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant