feat(release): add pre-release publishing to private registry - #307
Merged
Conversation
Trying an unreleased change against a downstream project required cutting a real release on npm / PyPI / NuGet / Maven Central. All four are immutable, so every experiment spent a version number, moved `latest`, and reached every consumer on a caret range. This adds the private path. - `bun run prerelease` publishes the in-development version to a registry named in tools/prerelease/registry.env (gitignored). One canonical `<base>-rc.<N>`, normalized in one place: 0.24.0-rc.3 (npm/NuGet), 0.24.0rc3 (PEP 440), 7.24.0-rc.3 (Maven). The collision-breaker is a counter, not a commit sha, because npm strips SemVer build metadata. - `tools/prerelease/prerelease-link.sh link|unlink|check` points a consumer at the registry and takes it back off: namespace-scoped config only, and unlink repins EVERY vendor dependency (missing one fails the next install with notarget), drops the lockfile, and runs the detector to prove it clean. - `tools/prerelease/detect-prerelease-pins.sh` is the guard a consumer commits. The registry is a public HTTPS endpoint with anonymous reads, so no network boundary is doing safety work — this check IS the containment. - `scripts/check-no-prerelease-versions.sh` runs in pre-commit and the gates lane: a committed -rc.N silently shrinks the next real release, because release.mjs derives the lockstep set from the CLI's current version. Config is per-project, never machine-global: a user-level ~/.npmrc is invisible to the detector, switches every project at once, and a silent fall-back to one is how a pre-release reached public npm while this was being built. Every publish path now asserts its target, checks a deny-list of public registries, parses `bun publish --dry-run` rather than trusting bun, and redirects HOME so a fall-back has no credential. Also fixes release.mjs preflighting only the cli: a version already taken by any other package in the set was found mid-publish, after its dependencies had shipped irreversibly. @metaobjectsdev/metadata@0.24.0-rc.1 is exactly that case — permanently on npm, unpublish refused (E405), and carried by no other package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-closed preflight, detector, bootstrap password
The fix-review round replaced the detector's naked version-anywhere match with a
proximity window, which stopped a third-party `rc`/`beta` pin from failing the
check on its own. It did not go far enough: the window ran over every manifest,
so a project with a RELEASED vendor pin and an unrelated third-party beta a few
lines away still failed, permanently.
"typescript": "5.9.0-beta",
"@metaobjectsdev/cli": "^0.23.2" <-- released, yet the file was flagged
The window only earns its keep in lockfiles, where a package name and its version
are on different lines and no same-line match can read the pair. Every other
manifest format keeps them on one line, where the namespace-anchored arm already
reads them exactly. So the window is now lockfile-only.
This is the failure the feature's own docs argue against: a containment check
that cries wolf is one people learn to suppress, and this check is load-bearing —
the registry is a public endpoint, so no network boundary is doing the work.
Verified: a project pinning only third-party pre-releases is clean; a released
vendor pin beside a third-party beta is clean; a vendor pre-release in a manifest
is flagged; a vendor pre-release in a lockfile is still flagged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… shellcheck warnings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Testing an unreleased change against a downstream project required cutting a real release on npm / PyPI / NuGet / Maven Central. All four are immutable, so every experiment spent a version number, moved
latest, and reached every consumer on a caret range.0.21.2had to be cut within an hour of0.21.1for exactly this reason.This adds the private path: publish a pre-release to a separate registry, consume it downstream, iterate, and switch back with one verified command.
What
bun run prereleasetools/prerelease/registry.env(gitignored)tools/prerelease/prerelease-link.sh link|unlink|checktools/prerelease/detect-prerelease-pins.shscripts/check-no-prerelease-versions.shgateslane: no pre-release version may be committed heredocs/features/prerelease.mdtools/prerelease/docker-compose.yml+bootstrap.shVersion scheme
One canonical string
<base>-rc.<N>, normalized in exactly one place (const Vinscripts/prerelease.mjs):0.24.0-rc.30.24.0-rc.30.24.0rc30.24.0-rc.37.24.0-rc.3The collision-breaker is a counter, not a commit sha — measured, not assumed: npm strips SemVer build metadata, so
0.24.0-rc.1+aaaand+bbbare the same version to it and the second publish is refused as a duplicate. The sha still travels in package metadata (C#AssemblyInformationalVersionvia Source Link) where it identifies a build without participating in resolution.-rc.Nsorts correctly everywhere, numerically in Maven (rc.2beforerc.10), and below the eventual release — neither^0.23.2nor^0.24.0matches0.24.0-rc.1. (-next.Nwould be a trap: Maven ranks an unknown qualifier above the plain release.)Why per-project config, never machine-global
A user-level
~/.npmrcwould be less typing and is the wrong answer: it is invisible to the detector, it switches every project at once, and — the reason this is a rule rather than a preference — a silent fall-back to user-level config is how a pre-release reached public npm while this was being built.bun publishignoresnpm_config_userconfig, found~/.npmrc, and shipped for real.So every publish path now: asserts its target equals the configured registry, checks it against a deny-list of the public registries, parses
bun publish --dry-runrather than trusting bun, and redirectsHOMEso a fall-back has no credential. The redundancy is deliberate.The registry is a public HTTPS endpoint with anonymous reads, so no network boundary provides containment — the leak detector is load-bearing, not belt-and-braces, and the docs say so.
Also fixed:
release.mjspreflighted only one packageThe target-version check ran
npm view @metaobjectsdev/cli@<version>and nothing else, so a version already taken by any other package in the lockstep set surfaced mid-publish, after its dependencies had shipped irreversibly.Not hypothetical —
@metaobjectsdev/metadata@0.24.0-rc.1is permanently on public npm (unpublish refused,E405; deprecation does not free the number) and no other package in the set carries it, so a lockstep RC at0.24.0-rc.1would publish thirteen packages and then fail on the fourteenth.bun run prereleasealso skips numbers burned on public npm when choosing an iteration: withrc.1burned andrc.1–rc.3used privately, it selectsrc.4.Verified end-to-end against the live registry
1. publish
2. link + install (scratch project outside the repo)
3. detector catches the linked project
4. unlink and prove it clean
5. all four ecosystems (registry contents)
Python and Maven consumers were round-tripped the same way (link → resolve → unlink → resolve from public). A Maven consumer needs one extra thing against a plain-
httpregistry — Maven 3.8.1+ blocks those via a built-inmaven-default-http-blockermirror, and the error names the blocker rather than the cause;linkwrites.mvn/settings.xml+.mvn/maven.configusing-gs(which merges with the user's own settings rather than replacing them) andunlinkdeletes both. The project registry is HTTPS, so that path does not trigger for it.6. outside collaborators
Reads are anonymous, so someone outside the project needs no account and no token — verified with the two scripts copied to a bare directory, no
registry.env, and onlyMO_REGISTRY_OWNERin the environment:7. the guards
Detector false-positive check — five projects, no false alarms:
Not in this PR
.github/workflows/publish-npm.ymlpublishes a hardcoded list of 13 directories that omits@metaobjectsdev/docs-site, whichclidepends on — a CI-driven release would ship an uninstallablecli. Real, but a separate change.Review rounds (what the gate caught in my work)
Two fix rounds, seven findings accepted. Several were reproduced before being accepted.
Round 1 — three real bugs plus two I promoted from
no-op:restore()reverted whole treesserver/orclient/web/was silently discarded — including on--dry-runhas_nuget()could essentially never be truels a b cexits non-zero if any argument is missing; reproduced — a normalMyApp.sln+src/MyApp/MyApp.csprojlayout exits 2, solinksilently skipped NuGetif (!r.ok) return nulltreated 429/500 as "version free", re-creating the irreversible mid-publish failure the change exists to preventno-op)no-op)Round 2 — two defects in round 1's fixes:
git ls-files -- 'server/java/**/pom.xml'returns 18 of 19 poms:**still demands a separator, so the reactor root is excluded — while the Java branch always rewrites it. The data-loss hole survived for exactly one file. Gate and restore now derive from one source, andtouch()aborts on any path the gate did not cover.bun installleftbun.lockdeleted and unrecoverable. Registration now precedes every mutation.Found afterwards, fixed on top (
8d01654a2): round 2's proximity window ran over every manifest, so a project with a released vendor pin two lines from an unrelated third-party beta failed permanently. The window is only needed in lockfiles, where name and version sit on different lines; it is now lockfile-only.Re-verified against the shipped code
Detector precision, final: