Skip to content

feat(release): add pre-release publishing to private registry - #307

Merged
dmealing merged 6 commits into
mainfrom
fm/mo-prerelease-p4
Aug 18, 2026
Merged

feat(release): add pre-release publishing to private registry#307
dmealing merged 6 commits into
mainfrom
fm/mo-prerelease-p4

Conversation

@dmealing

@dmealing dmealing commented Aug 18, 2026

Copy link
Copy Markdown
Member

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.2 had to be cut within an hour of 0.21.1 for 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 prerelease publishes the in-development version to the registry named in tools/prerelease/registry.env (gitignored)
tools/prerelease/prerelease-link.sh link|unlink|check points a consumer at the registry and takes it back off
tools/prerelease/detect-prerelease-pins.sh the guard a consumer commits and runs in CI
scripts/check-no-prerelease-versions.sh pre-commit + gates lane: no pre-release version may be committed here
docs/features/prerelease.md the adopter-facing guide
tools/prerelease/docker-compose.yml + bootstrap.sh optional self-hosted registry for a fork or an offline machine

Version scheme

One canonical string <base>-rc.<N>, normalized in exactly one place (const V in scripts/prerelease.mjs):

canonical npm PyPI NuGet Maven
0.24.0-rc.3 0.24.0-rc.3 0.24.0rc3 0.24.0-rc.3 7.24.0-rc.3

The collision-breaker is a counter, not a commit sha — measured, not assumed: npm strips SemVer build metadata, so 0.24.0-rc.1+aaa and +bbb are the same version to it and the second publish is refused as a duplicate. The sha still travels in package metadata (C# AssemblyInformationalVersion via Source Link) where it identifies a build without participating in resolution.

-rc.N sorts correctly everywhere, numerically in Maven (rc.2 before rc.10), and below the eventual release — neither ^0.23.2 nor ^0.24.0 matches 0.24.0-rc.1. (-next.N would be a trap: Maven ranks an unknown qualifier above the plain release.)

Why per-project config, never machine-global

A user-level ~/.npmrc would 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 publish ignores npm_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-run rather than trusting bun, and redirects HOME so 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.mjs preflighted only one package

The 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.1 is 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 at 0.24.0-rc.1 would publish thirteen packages and then fail on the fourteenth.

$ <the new preflight logic, against real npm>
0.24.0-rc.1: REFUSED — already published for @metaobjectsdev/metadata
0.24.0-rc.4: free on all 14 packages
0.23.2:      REFUSED — already published for @metaobjectsdev/react, … (all 14)

bun run prerelease also skips numbers burned on public npm when choosing an iteration: with rc.1 burned and rc.1rc.3 used privately, it selects rc.4.

Verified end-to-end against the live registry

1. publish


── pre-release 0.24.0-rc.4 → gitea.mealing.com ──

  released: 0.23.2   base: 0.24.0   iteration: 4
  npm 0.24.0-rc.4  ·  pypi 0.24.0rc4  ·  nuget 0.24.0-rc.4  ·  maven 7.24.0-rc.4
  ports: npm

✓ publish target verified: gitea.mealing.com (configured registry; not a public one)
✓ version-bearing files clean
✓ npm lockstep set: 14 packages → 0.24.0-rc.4
✓ relocked + clean rebuild
✓ packed deps pinned to 0.24.0-rc.4
✓ bun publish target verified by dry-run: https://gitea.mealing.com/api/packages/doug/npm/
  published metadata@0.24.0-rc.4
  published render@0.24.0-rc.4
  published codegen-ts@0.24.0-rc.4
  published runtime-ts@0.24.0-rc.4
  published migrate-ts@0.24.0-rc.4
  published sdk@0.24.0-rc.4
  published docs-site@0.24.0-rc.4
  published runtime-web@0.24.0-rc.4
  published codegen-ts-react@0.24.0-rc.4
  published codegen-ts-tanstack@0.24.0-rc.4
  published react@0.24.0-rc.4
  published tanstack@0.24.0-rc.4
  published cli@0.24.0-rc.4
  published ai-runtime@0.24.0-rc.4
✓ npm: 14 packages @ 0.24.0-rc.4 (dist-tag prerelease)

✅ 0.24.0-rc.4 is on the pre-release registry.
   link a consumer: tools/prerelease/prerelease-link.sh link --project <dir> --version 0.24.0-rc.4
   consumer guide:  docs/features/prerelease.md

2. link + install (scratch project outside the repo)


── linking /tmp/mo-pr-consumer → https://gitea.mealing.com (owner doug) ──
  pinning vendor dependencies to 0.24.0-rc.4
  wrote .npmrc (scope @metaobjectsdev only) + git local exclude
  repinned every @metaobjectsdev/* dependency to 0.24.0-rc.4
  installed tools/prerelease/detect-prerelease-pins.sh — COMMIT IT and run it in CI

✓ linked. Install/restore, then iterate:
  npm    rm -f package-lock.json && npm install

! this project now depends on artifacts that exist ONLY on the private registry.
! run 'unlink' before pushing anything from it.

$ cat .npmrc
# >>> metaobjects prerelease (managed) >>>
@metaobjectsdev:registry=https://gitea.mealing.com/api/packages/doug/npm/
# <<< metaobjects prerelease (managed) <<<

$ npm install && npx meta --version
0.24.0-rc.4

$ where did each dependency come from?
  11 @metaobjectsdev/* packages, all from the pre-release registry:
    @metaobjectsdev/cli  <- https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fcli/-/0.24.0-rc.4/cli-0.24.0-rc.4.tgz
  third-party stays public:
    zod  <- https://registry.npmjs.org/zod/-/zod-3.25.76.tgz

3. detector catches the linked project

$ tools/prerelease/detect-prerelease-pins.sh .
  ✖ project points at the pre-release registry (gitea.mealing.com)
      ./.npmrc:2:@metaobjectsdev:registry=https://gitea.mealing.com/api/packages/doug/npm/
      ./package-lock.json:564:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fcli/-/0.24.0-rc.4/cli-0.24.0-rc.4.tgz",
      ./package-lock.json:596:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fcodegen-ts/-/0.24.0-rc.4/codegen-ts-0.24.0-rc.4.tgz",
      ./package-lock.json:613:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fcodegen-ts-react/-/0.24.0-rc.4/codegen-ts-react-0.24.0-rc.4.tgz",
      ./package-lock.json:627:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fcodegen-ts-tanstack/-/0.24.0-rc.4/codegen-ts-tanstack-0.24.0-rc.4.tgz",
      ./package-lock.json:641:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fdocs-site/-/0.24.0-rc.4/docs-site-0.24.0-rc.4.tgz",
      ./package-lock.json:652:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fmetadata/-/0.24.0-rc.4/metadata-0.24.0-rc.4.tgz",
      ./package-lock.json:661:      "resolved": "https://gitea.mealing.com/api/packages/doug/npm/%40metaobjectsdev%2Fmigrate-ts/-/0.24.0-rc.4/migrate-ts-0.24.0-rc.4.tgz",
exit status: 1

4. unlink and prove it clean

  no --to given; using the current published release 0.23.2

── unlinking /tmp/mo-pr-consumer → public registries @ 0.23.2 ──
  removed managed registry config
  repinned every @metaobjectsdev/* dependency to 0.23.2
  dropped lockfile(s): package-lock.json

detect-prerelease-pins: ✓ no private-registry config and no pre-release pin
✓ unlinked and verified clean — reinstall to regenerate the lockfile from public registries

$ npm install && npx meta --version
0.23.2
  cli <- https://registry.npmjs.org/@metaobjectsdev/cli/-/cli-0.23.2.tgz
  local-registry references left in the lockfile: 0
0

5. all four ecosystems (registry contents)

total: 34   {'maven': 15, 'nuget': 4, 'pypi': 1, 'npm': 14}
  maven    com.metaobjects:metaobjects              7.24.0-rc.2
  npm      @metaobjectsdev/cli                      0.24.0-rc.4
  nuget    MetaObjects.Codegen                      0.24.0-rc.2
  pypi     metaobjects                              0.24.0rc2

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-http registry — Maven 3.8.1+ blocks those via a built-in maven-default-http-blocker mirror, and the error names the blocker rather than the cause; link writes .mvn/settings.xml + .mvn/maven.config using -gs (which merges with the user's own settings rather than replacing them) and unlink deletes 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 only MO_REGISTRY_OWNER in the environment:

$ MO_REGISTRY_OWNER=<owner> tools/prerelease/prerelease-link.sh link --project app --version 0.24.0-rc.3
  wrote .npmrc (scope @metaobjectsdev only) + git local exclude
$ cat app/.npmrc
  @metaobjectsdev:registry=https://gitea.mealing.com/api/packages/<owner>/npm/     # no token
$ npm install && npx meta --version
0.24.0-rc.3
$ tools/prerelease/prerelease-link.sh unlink        # no env vars needed at all
✓ unlinked and verified clean
0.23.2

7. the guards

$ scripts/check-no-prerelease-versions.sh          # with a planted -rc.9 in all four ports
  ✖ server/typescript/packages/cli/package.json: version 0.24.0-rc.9 is a pre-release
  ✖ server/python/pyproject.toml:                version 0.24.0rc9  is a pre-release
  ✖ server/csharp/Directory.Build.props:          version 0.24.0-rc.9 is a pre-release
  ✖ server/java/pom.xml:                          version 7.24.0-rc.9 is a pre-release
exit 1

$ git commit                                        # the pre-commit hook holds
  commit blocked — see above. Bypass (discouraged):  git commit --no-verify

$ scripts/ci-local.sh --only gates
  ✓ leak-scan (security)          ✓ publish-intent parity
  ✓ pom-version parity            ✓ no committed pre-release version      <-- new
  ✓ bun-version parity            ✓ peer-range bounds
  ✓ fixture-lint                  ✓ doc-template drift
  ✓ embedded-library drift
LOCAL CI PASSED

Detector false-positive check — five projects, no false alarms:

consumer-npm     CLEAN      consumer-python   CLEAN      leaky-fixture   FLAGGED
consumer-maven   CLEAN      consumer-csharp   CLEAN      leaky-maven     FLAGGED
   (its own 1.0.0-SNAPSHOT project version is correctly NOT flagged)

Not in this PR

.github/workflows/publish-npm.yml publishes a hardcoded list of 13 directories that omits @metaobjectsdev/docs-site, which cli depends on — a CI-driven release would ship an uninstallable cli. 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:

finding why it mattered
restore() reverted whole trees the entry gate only checked the version-file globs, so unstaged work anywhere under server/ or client/web/ was silently discarded — including on --dry-run
has_nuget() could essentially never be true ls a b c exits non-zero if any argument is missing; reproduced — a normal MyApp.sln + src/MyApp/MyApp.csproj layout exits 2, so link silently skipped NuGet
release preflight failed open on 5xx if (!r.ok) return null treated 429/500 as "version free", re-creating the irreversible mid-publish failure the change exists to prevent
detector fired on any third-party beta (was no-op) contradicts this PR's own argument that a check which cries wolf gets suppressed
committed admin password (was no-op) a default credential in a public repo; now generated and persisted per-machine

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, and touch() aborts on any path the gate did not cover.
  • Paths were registered after mutation, so a failed bun install left bun.lock deleted 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

unrelated WIP survives a --dry-run ................ ✓ (pre-fix code reverted it)
touch() aborts on an uncovered path ............... ✓ "about to mutate README.md, which the
                                                       clean-tree gate never covered"
has_nuget on MyApp.sln + src/MyApp/MyApp.csproj ... ✓ NuGet.config written, refs repinned
live npm round trip ............................... ✓ link → 0.24.0-rc.4 → FLAGGED → unlink
                                                       → 0.23.2, 0 private refs in lockfile

Detector precision, final:

third-party betas only ......................... CLEAN
released vendor pin beside a third-party beta ... CLEAN
vendor pre-release in a manifest ................ FLAGGED
vendor pre-release in a lockfile ................ FLAGGED
six pre-existing fixtures ....................... unchanged

dmealing and others added 6 commits August 17, 2026 20:23
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>
@dmealing dmealing changed the title feat(release): pre-release publishing to a private registry feat(release): add pre-release publishing to private registry Aug 18, 2026
@dmealing
dmealing merged commit 7920a69 into main Aug 18, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-prerelease-p4 branch August 18, 2026 02:15
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