Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ hits=$(git diff --cached --diff-filter=ACM -U0 --no-color 2>/dev/null | awk '
/^\+/ && !/^\+\+\+/ { if (f !~ /^\.githooks\//) print f "\t" substr($0,2) }
' | grep -Ei "$PATTERNS" | grep -vEi "$ALLOW")

# A pre-release version must never be committed: scripts/release.mjs derives the lockstep
# set from the CLI's CURRENT version, so a stray -rc.N silently shrinks the next release.
# Only runs when a version-bearing file is actually staged.
if git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -qE \
'(package\.json|pom\.xml|pyproject\.toml|Directory\.Build\.props)$'; then
if [ -x "$ROOT/scripts/check-no-prerelease-versions.sh" ]; then
if ! "$ROOT/scripts/check-no-prerelease-versions.sh" >/dev/null 2>&1; then
echo "" >&2
"$ROOT/scripts/check-no-prerelease-versions.sh" >&2 || true
echo " commit blocked — see above. Bypass (discouraged): git commit --no-verify" >&2
exit 1
fi
fi
fi

if [ -n "$hits" ]; then
echo "" >&2
echo " commit blocked — possible private/other-project or local-path leak (metaobjects is PUBLIC):" >&2
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ server/java/.claude/

# Serena MCP project cache (local tooling scratch)
.serena/

# Private pre-release registry address + token (docs/features/prerelease.md).
# Never committed: this repository is PUBLIC.
tools/prerelease/registry.env
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,64 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Added — pre-release publishing to a private registry (no more real releases just to test a change)

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 was visible to every consumer on a caret range. There is
now a private path: publish a **pre-release** to a separate registry, consume it downstream,
iterate, and switch back with one verified command.

- **`bun run prerelease`** (`scripts/prerelease.mjs`) — publishes the in-development version
to a registry configured in `tools/prerelease/registry.env` (gitignored). One canonical
version string `<base>-rc.<N>`, normalized in exactly one place: `0.24.0-rc.3` (npm,
NuGet), `0.24.0rc3` (PEP 440), `7.24.0-rc.3` (Maven). npm by default, `--only all` for the
four ports. The collision-breaker is a **counter, not a commit sha**, because npm strips
SemVer build metadata — `0.24.0-rc.1+aaa` and `+bbb` are the same version to it.
- **`tools/prerelease/prerelease-link.sh link|unlink|check`** — points a downstream project
at the registry, and takes it back off. It detects the project's ecosystems, writes only
namespace-scoped config (`@metaobjectsdev/*`, `metaobjects`, `MetaObjects*`,
`com.metaobjects` — everything else keeps resolving publicly), and on `unlink` repins
**every** vendor dependency, drops the lockfile, and runs the detector to prove the
project is clean. Repinning only the dependency you installed is not enough: `meta init`
writes `@metaobjectsdev/codegen-ts` and `@metaobjectsdev/metadata` into a consumer's
devDependencies too, and missing them fails the next clean install with `notarget`.
- **`tools/prerelease/detect-prerelease-pins.sh`** — the guard a consumer commits and runs
in CI. The registry is a public HTTPS endpoint with anonymous reads, so no network
boundary is doing safety work; this check *is* the containment. It scans dependency
declarations only (a test server bound to `127.0.0.1` is not a dependency on anything) and
knows the registry host by default, so a consumer repo that has never seen the publisher's
config still catches a leak.
- **`scripts/check-no-prerelease-versions.sh`** — wired into `.githooks/pre-commit` and the
`gates` lane. A committed `-rc.N` is not cosmetic: `scripts/release.mjs` derives the
lockstep set from the CLI's *current* version, so one stray pre-release version silently
drops that package from the next real release.
- `tools/prerelease/docker-compose.yml` + `bootstrap.sh` stand up an equivalent registry for
a fork or an offline machine; the publisher is registry-agnostic either way.
- Adopter-facing guide: [`docs/features/prerelease.md`](docs/features/prerelease.md).

**Config is per-project and never machine-global**, deliberately. A user-level `~/.npmrc` is
invisible to the detector, 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 the exact mechanism
that published a pre-release to public npm while this was being built: `bun publish` ignores
`npm_config_userconfig`, found `~/.npmrc`, and shipped for real. 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 runs with
`HOME` redirected so a fall-back has no credential to use.

### Fixed — `scripts/release.mjs` preflighted only one package

The target-version check ran `npm view @metaobjectsdev/cli@<version>` and nothing else, so a
version already published for any *other* package in the lockstep set was discovered
mid-publish — after its dependencies had shipped irreversibly. That is not hypothetical:
`@metaobjectsdev/metadata@0.24.0-rc.1` exists on public npm 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. npm versions cannot be reclaimed — `unpublish` is *refused* (`E405`) once
anything depends on the version, and deprecation does not free the number. The preflight now
checks every package in the set (in parallel, so it stays fast), and `bun run prerelease`
skips numbers already burned on public npm when choosing an iteration.


## [0.23.2] — npm `0.23.2` · PyPI `0.23.2` · NuGet `0.23.2` · Maven `7.23.2`

A coordinated **PATCH** across all four registries.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ PRs welcome. When contributing:

For significant new features or architectural changes, open an issue first to discuss the approach.

**Publishing to npm:** see [docs/RELEASING.md](docs/RELEASING.md) — the procedure (RC → smoke-test → promote) plus the non-obvious gotchas (publish with `bun`, regen the lockfile after every version bump, runtime imports must be `dependencies`, verify a real external install in npm *and* pnpm).
**Publishing:** To iterate an unreleased change against a downstream project, use [docs/features/prerelease.md](docs/features/prerelease.md) — publish to the private registry (`bun run prerelease`), consume it, iterate, and revert with one verified command. For full public releases, see [docs/RELEASING.md](docs/RELEASING.md) — the procedure (RC → smoke-test → promote) plus the non-obvious gotchas (publish with `bun`, regen the lockfile after every version bump, runtime imports must be `dependencies`, verify a real external install in npm *and* pnpm).

## Roadmap pointer

Expand Down
24 changes: 22 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,14 @@ Publish in tier order so a dependent never lands before its dependency. **`forge
pnpm** (pnpm's strict, non-nested `node_modules` exposes resolution bugs npm/bun hide). Install
the cli into a throwaway dir, run `meta --version`, `meta init`, `meta gen`.

5. **npm versions are immutable.** You can never re-publish a version, and unpublish is a
restricted 72-hour escape hatch. That's why we go RC-first.
5. **npm versions are immutable, and a burned one never comes back.** You can never
re-publish a version. Unpublish is not a reliable escape hatch: it is *refused* (`E405`)
once anything depends on the version, and deprecating it does not free the number.
`@metaobjectsdev/metadata@0.24.0-rc.1` is burned that way 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 irreversibly on the fourteenth. `scripts/release.mjs` now preflights the target
version against **every** package in the set (it used to check only the cli), and
`bun run prerelease` skips burned numbers when choosing an iteration.

## Versioning policy (pre-1.0)

Expand Down Expand Up @@ -224,6 +230,20 @@ bun run clean && bun run build
Spot-check `dist` reflects the change (a deleted source's `.js` is gone, new code present).

### 1. Release candidate → `next`

> **Most changes do not need this.** To try an unreleased change against a downstream
> project, publish a PRE-RELEASE to the private registry instead —
> [`docs/features/prerelease.md`](features/prerelease.md), `bun run prerelease`. It is
> reversible, invisible to the public registries, and costs no version number.
>
> A **public** RC is for the one case a private registry cannot cover: **dependencies or
> package layout changed**, so the thing being tested IS a real external install from the
> real registry — a misclassified `dependencies`/`devDependencies` entry, a peer range, a
> new package name, an `exports` map. Rule 4 below only means something against npmjs.org.
>
> Remember what it costs: an RC version is permanent. Once anything depends on it,
> `npm unpublish` is refused outright and deprecating it does not free the number.

```bash
# bump the candidate set to <version>-rc.N (sed the "version" field in each publish-candidate package.json)
rm bun.lock && bun install # CRITICAL — re-pins workspace versions
Expand Down
Loading
Loading