Four bugs one version-numbering mistake found
v3.0.0 already shipped as a real, public GitHub Release. Cutting the next release candidate from it produced a version number lower than what was already live, and chasing down why surfaced three more real bugs behind it.

scripts/release.sh rc cuts a release candidate of the current version: 3.0.1 becomes 3.0.1-rc1, no version bump, just a suffix. Running it against this repository produced v3.0.0-rc1. That looks fine in isolation. It is not fine next to the fact that v3.0.0 was already a real, public, Latest GitHub Release, published days earlier. v3.0.0-rc1 sorts lower than v3.0.0 under semver: a release candidate for a version that had already shipped.
Bug one: rc mode trusted the current version blindly
rc mode appended -rc1 to whatever VERSION already said, with no check for whether that base version was already tagged and released on origin. It should have refused and asked for a real bump first. The tag was deleted before anything public was created from it (no GitHub Release, no npm publish, nothing to roll back), and rc mode now checks origin before cutting anything: if the current, clean version is already released, it refuses outright instead of silently producing a version number that sorts backward.
Bug two: two files that were supposed to move together, didn't
devforgekit doctor --release-check's version-consistency gate caught this one immediately, on the very first real run against the corrected v3.0.1-rc1 tag: VERSION said 3.0.1-rc1, but package.json and cli/package.json still said 3.0.0. scripts/release.sh create bumped VERSION and drafted the changelog heading, but never touched either package.json's own "version" field. Both now move together with VERSION on every release cut.
Bug three: a check that tested the wrong file
Writing the regression test for bug two surfaced a second, independent bug in the checker itself: checkVersionConsistency()'s cli/package.json comparison read its path through a cliRoot() helper that always resolved to this checkout's real, live path, completely ignoring the root parameter the function itself had been passed. A test built to exercise the check against an isolated scratch directory was silently validating this actual repository instead. Fixed to read path.join(root, "cli", "package.json") like every other path in the same function already did.
// before: always this checkout's real path, root param ignored
const cliPkgPath = cliRoot();
// after: respects the root the function was actually called with
const cliPkgPath = path.join(root, "cli", "package.json");Bug four: a stray file named nul, left by every CI run
The clean-working-tree gate started failing right after npm test: a file named nul kept appearing in cli/ after every test run, tripping the check meant to catch exactly this kind of unintended write. WindowsPlatform.osVersion() shells out to cmd /c ver 2>nul to read the OS version string on Windows. 2>nul is cmd.exe syntax for "discard stderr", but the shell command runner executes through whatever shell is native to the current host, and on every CI runner and every development machine involved here, that's a POSIX shell. /bin/sh doesn't know what 2>nul means as a redirect target; it interprets nul as a literal filename and creates it. osVersion() now returns null immediately on any non-Windows host, without spawning a process at all.
Why this is worth writing down
None of these four bugs were hiding in obscure code paths. They were caught by running the real release process against a real repository and treating every gate failure as a genuine signal rather than something to route around. The version-numbering mistake that started this was corrected to v3.0.1-rc1, the real release candidate this cycle actually shipped from a few days later. Every fix here has a regression test; the full suite (1,315 of 1,316 tests, the one unrelated flake independently reproduced and understood) passed before any of it merged.