fix: publish the version the workspace holds, not the changelog's - #1458
Conversation
`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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
| // 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)})`); |
There was a problem hiding this comment.
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:
| console.log(`[publish-npm] published ${pkgName}@${treeVersion ?? version} (${basename(file)})`); | |
| console.log( | |
| `[publish-npm] published ${pkgName}@${treeVersion ?? `${version} (assumed: workspace lookup failed)`} (${basename(file)})`, | |
| ); |
| assert.doesNotMatch( | ||
| r.stdout, | ||
| /can no longer be published from this tree/, | ||
| `the guard must not fire when the versions agree, got: ${r.stdout}`, | ||
| ); |
There was a problem hiding this comment.
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.
Summary
npm publish --workspace=<pkg>ships the version in that workspace'spackage.json.scripts/publish-npm.jstook 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_pathsdispatch input added in #1457. That happened for real while clearing the backlog: republishingchangelog/core/0.7.52.mdshipped 0.7.53 (the tree's version) under the log linepublished @webjsdev/core@0.7.52, then the next file in the batch died withE403 cannot publish over the previously published versions: 0.7.53, takingserver@0.8.66andcli@0.10.57with it underset -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.jsresolves the workspace's real version through the same--workspace=lookupnpm publishuses (rather than guessing a directory from the package name, which would have to special-casepackages/editors/intellisenseandpackages/wrappers/*), and skips with a message naming both versions when the tree has moved past the changelog's.npm pkg getoutput fails open rather than blocking a release, since it is not evidence of a mismatch.framework-dev.mddocuments 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 itschangelog-editor-packages.test.mjssibling:publish-npm.jsto its pre-fix state atHEAD~1fails the first test withexpected a clean skip, got status 1, while the second stays greenchangelog-editor-packages.test.mjsstill passes 4/4, so thenpm: falseskip path is intactDocs surfaces
framework-dev.md: updated, the republish recovery section.Note
core@0.7.52stays unpublished and unpublishable, which this PR makes explicit rather than fixing. It is fully superseded bycore@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.