Development & releasing
Architecture boundaries #
Dependency direction between workspace members is mechanically enforced.
pnpm check:boundaries (also a step in pnpm validate and CI) validates every
static import against the policy in scripts/boundaries.config.json:
- Layering (lowest first):
@maxstack/spec,@maxstack/core,@maxstack/ui(foundation — no workspace imports;uipublishes standalone) →@maxstack/features,@maxstack/mcp,@maxstack/spec-derive. A package may only import packages its policy entry lists. - Apps (
apps/*) may import any package; nothing may import an app. - Relative imports may not escape their own package root — import the package by name instead.
Remediating a violation, in order of preference:
- Move the code that needs the import down the stack (or the importing code up) so the dependency points the allowed way.
- Extract the shared piece into the lower-level package both sides can import.
- Only if the layering itself is wrong: change
scripts/boundaries.config.jsonin its own reviewed commit explaining the new direction — never as a drive-by edit to make a feature branch pass.
A braceless if never guards a ;-prefixed statement (the format trap) #
This repo omits semicolons, so a statement beginning with ( or [ carries a
leading ;. Never put that form in the body of a braceless if or else:
if (cond) ;(expr) // biome format rewrites this to:
if (cond); // an if guarding an empty statement
;(expr) // and an expression that now always runsThe rewrite is valid syntax, formats clean, typechecks and lints. It shipped once and turned a guarded assertion into one that fired on every path, with the test still passing for the wrong reason. Only reading the diff caught it.
pnpm check:guarded-statements (a step in pnpm validate) reads it
mechanically instead: it rejects any braceless if/else whose body is an
empty statement, in both the source shape and the formatted shape. Brace the
body — an intentionally empty branch is {}, which noEmptyBlockStatements
then judges on its own merits.
Client-persisted state in SSR components (the hydration trap) #
apps/web server-renders. Any component that branches on client-persisted
state (the PreferenceStore behind useStore — theme, density, column
visibility, saved queries, banner dismissal) can therefore render one thing on
the server and another during hydration.
The trap is useSyncExternalStore's third argument. getServerSnapshot must
return what the server rendered, and React calls it on the client during
hydration too. Passing the same getSnapshot that reads localStorage looks
SSR-safe and guarantees a mismatch for every visitor whose persisted value
differs from the fallback. That shipped once and cost two bugs:
a zombie cookie banner (SSR DOM stranded with dead handlers) and the
banner nagging.
The rules, in order of preference:
- Just use
useStore. It is hydration-safe: it server-snapshots the fallback and flips to the persisted value right after hydration. Do not hand-roll amountedflag around it. - If the flip is user-visible and unacceptable (a banner that must not
flash for someone who dismissed it), use
useHydratedStore(key, fallback)→[value, setValue, hydrated]and render nothing untilhydrated, or wrap the subtree in<ClientOnly>. - Never pass a browser-reading function as
getServerSnapshotin newuseSyncExternalStorecalls.useSystemThemeinprefs/theme.tsxis the reference:() => 'light', notmatchMedia.
Review checklist item: for any component that renders during SSR — does it
branch on client-persisted state? If yes, it must reach the value through
useStore/useHydratedStore, not through a bespoke store read.
A client-only render() test cannot catch this class: it never exercises
getServerSnapshot, which is exactly why the original bug shipped green. The
regression tests in packages/ui/src/prefs/prefs-context.hydration.test.tsx
drive a real renderToString + hydrateRoot and assert on React's
onRecoverableError — copy that shape when testing SSR-sensitive UI, and note
that asserting on the final DOM alone is not enough (React recovers from a
mismatch and still lands on the right DOM).
Testing a runtime change inside a real project (maxstack runtime link) #
Most user-visible defects live in the runtime (apps/web + the packages the
build bundles), not in the spec layer — and the only honest way to test a fix is
to watch it inside a project generated by an installed CLI. Do not rebuild
@maxstack/web and copy build/ over the global maxstack-runtime install:
that folk procedure patches one machine, is invisible afterwards, and the old
bundle survives in a running dev server's memory anyway.
Link the checkout instead:
maxstack runtime link "$PWD" ~/scratch-project # from maxstack/ (the dir with apps/web)
maxstack dev ~/scratch-project # vite dev server from the checkout — HMR, no build step
maxstack runtime status ~/scratch-project # which runtime resolves, and why
maxstack runtime unlink ~/scratch-project # back to the installed runtimeThe link is recorded in the project's data dir (gitignored — local to your
machine, never inherited from a commit), and resolveRuntime reads it before
anything else, so dev, demo, build and deploy all follow it. Every one
of them prints a LINKED RUNTIME banner, because a linked runtime is
unpublished code and an image built while linked contains it.
Source maps ship with every runtime build (build.sourcemap in
apps/web/vite.config.ts, MAXSTACK_NO_SOURCEMAP=1 to opt out). They cost
~2 MB in the published tarball and buy real file/line names in browser devtools
and — via --enable-source-maps, which maxstack dev and the Docker image pass
— in server stack traces. Don't drop them to shrink the package; the reason they
exist is that three shipped defects in a row were un-diagnosable without them
.
Releasing to npm: maxstack + maxstack-runtime #
Two packages ship in lockstep versions, staged by one script
(apps/maxstack/scripts/stage-npm.ts):
maxstack— the CLI. All five first-party@maxstack/*workspace packages are bundled intodist/lib/cli.js(they are never published on their own), plus the scaffoldtemplates/. It depends onmaxstack-runtimeat an exact-pinned version.maxstack-runtime— the web runtime, sodev/demo/build/deploywork from a bare npm install (no maxstack checkout): a prebuiltreact-router buildserver (build/), the demo seeder bundled to plain node (seed-demo.mjs), and aworkspace/source snapshot thatmaxstack buildvendors from (the samecloneWorkspacethe checkout path uses). The CLI picks checkout vs package viasrc/lib/runtime.ts.
Two npm-packing landmines the staging script handles — keep them in mind if
you touch it: npm always strips lockfiles from tarballs (the snapshot
ships pnpm-lock.snapshot.yaml, restored on vendor), and it strips/interprets
.gitignore/.npmignore/.npmrc (deleted from the snapshot).
npm owner: sys13. (The older standalone maxstack-core /
maxstack-web-template 0.x packages are a previous iteration — deprecated,
do not reuse.)
How the bundle works #
apps/maxstack/build.mjs runs esbuild with a resolve plugin that inlines
anything starting with ., /, or @maxstack/, and leaves everything else
(third-party packages + Node builtins) external. So:
- First-party workspace code → compiled into the one output file.
- Third-party deps → stay as normal
dependenciesinpackage.json, resolved from the user'snode_modulesat runtime.
Two things that are easy to break — don't:
- Output path depth. The bundle is emitted to
dist/lib/cli.js(two dirs deep) on purpose.src/lib/config.tscomputesHUB_ROOTas../..from its own location to findtemplates/; emitting at the same depth keeps that resolving to the package root after install. Do not flatten it todist/cli.js. - Shebang. esbuild preserves the entry file's
#!/usr/bin/env node. Do not add an esbuildbannerfor it — you'll get a duplicate shebang and a syntax error at runtime.
build.mjs also asserts that the .version('x') string in src/cli.ts matches
the version in package.json, so the two can't drift.
The dependency set (and why the smoke test is mandatory) #
Runtime dependencies must list every third-party package the bundled code
touches. esbuild's first-level metafile only reports direct external
imports — it misses deep transitive runtime needs. The known trap:
drizzle-orm/pglite's driver eagerly imports @electric-sql/pglite, and the
postgres-js path needs postgres — neither shows up as a direct import, but
both must be declared or the CLI crashes at load time.
The way to catch these is to pack the tarball and install it in a clean dir that has only the declared deps. Always do this before publishing. Current set:
@anthropic-ai/sdk @electric-sql/pglite commander diff
drizzle-orm postgres ts-morph zodOne install is not enough, and this is the second trap. A dependency that
declares a peer range nothing in the tree satisfies is still placed by the
first install — npm writes a copy on disk that no dependency edge points at — so
the CLI loads it and the smoke test goes green. The next npm install prunes
it, because pruning walks edges, and only then does the CLI die at load with
Cannot find package …. That is exactly how 0.11.11 shipped: npx maxstack init threw Cannot find package 'drizzle-orm' imported from @better-auth/drizzle-adapter for anyone whose npx cache had been written twice
(#348). So the smoke test runs the CLI, reinstalls, and runs it again — in
release.yml, in scripts/publish.ts, and in the recipe below. The other half
of that fix is scripts/bundle-externals.mjs, a two-directional ratchet on the
external set that fails the build (and pnpm test) when the bundle gains or
loses a dependency without the list being updated deliberately.
The @maxstack/* packages are build-only devDependencies (workspace:*).
They must NOT appear in the published manifest, so we publish from a staging
dir with a cleaned package.json (devDependencies + scripts stripped) rather
than from apps/maxstack directly — npm publish does not rewrite the
workspace: protocol, so publishing in place would ship an invalid manifest.
Cutting a release #
One command, from maxstack/:
pnpm release patch # or minor | major | 1.2.3 (--dry-run prints the plan)That is the whole release. scripts/cut-release.ts bumps the two version sites
in lockstep, regenerates docs/cli-reference.md (it stamps the version, so the
docs-reference gate goes red otherwise), commits
chore(release): maxstack@X.Y.Z, tags vX.Y.Z and pushes — it never talks to
npm. The tag push triggers
.github/workflows/release.yml, which does everything else on a runner:
guard version sites + tag agree; report what is already on the registry
stage pnpm install --frozen-lockfile → scripts/stage-npm.ts → upload both .tgz
smoke install both tarballs in a clean dir; --version, init, build --vendor-only,
then reinstall and init again (catches a pruned unsatisfiable peer)
publish maxstack-runtime FIRST, then maxstack (separate jobs, OIDC, no token)
verify npm view both versions + the CLI's runtime pin
release gh release create vX.Y.Z with the changelog section + both .tgz attachedThere is no npm login, no OTP and no NPM_TOKEN anywhere in that path: npm
trusted publishing trusts the named workflow in this repo through GitHub's
OIDC token, and stamps provenance on both packages as a side effect. Because the
runner builds from a clean clone at the tagged SHA, the tarballs also stop being
a snapshot of whoever's laptop cut the release.
To release from anywhere (no checkout — a phone works):
gh workflow run release.yml --ref main. The version comes from the committed
package.json; pass -f version=X.Y.Z to assert which release you think you
are cutting.
Preconditions are checks, not prompts — pnpm release aborts (it never asks)
on a non-main branch, a dirty tree, drifted version sites, an existing tag, or
a version already on the registry.
One-time npm setup (a package admin has to click this) #
Trusted publishing cannot be configured from a repo; someone with admin on both npm packages does this once, on npmjs.com:
- Sign in as the package owner and open
https://www.npmjs.com/package/maxstack-runtime→ Settings. - Under Trusted publisher, choose GitHub Actions and enter:
- Organization or user:
sys13 - Repository:
maxstack - Workflow filename:
release.yml - Environment: (leave empty — the workflow uses no GitHub environment) Save.
- Organization or user:
- On the same Settings page, set Publishing access to the most restrictive option — Require two-factor authentication and disallow bypass 2fa tokens. Every publishing-access option is compatible with a trusted publisher (npm says so inline on that page), so this setting does not gate CI at all; it only governs the human and token paths, and there is no reason to leave those loose. Trusted publishing plus the tightest token option is npm's own recommendation.
- Repeat steps 1–3 for
https://www.npmjs.com/package/maxstack. - Nothing else: no npm token is created, and no GitHub secret is added. If a
stale
NPM_TOKENsecret exists in the repo, delete it — it is unused and only widens the blast radius.
The workflow filename is part of the trust configuration: renaming
release.yml breaks publishing until the npm-side entry is updated to match.
When a release half-lands #
npm refuses to replace a published version, so the recovery for "runtime
published, CLI did not" is to publish only the missing half. That is automatic:
each publish job re-reads the registry immediately before publishing and skips
what is already there, so Re-run failed jobs on the same run publishes
exactly the missing package. The release state job runs on both success and
failure, prints the registry state to the job summary, and fails the run if
one package is on the registry without the other — a half-release can never end
in a green check.
Break-glass: publishing from a laptop #
If GitHub Actions is down, apps/maxstack's interactive
npm run release (scripts/publish.ts) still does the whole cut locally —
stage, smoke test, npm login (browser auth), publish runtime then CLI, verify,
commit, GitHub release. It needs the npm account owner at the keyboard, which is
exactly the dependency the CI path removes; use it only as a fallback. The
manual equivalent of its two publish steps is:
node --experimental-transform-types scripts/stage-npm.ts # from apps/maxstack
npm publish <maxstack>/dist-npm/maxstack-runtime-<v>.tgz --access public
npm publish <maxstack>/dist-npm/maxstack-<v>.tgz --access public
npm view maxstack version dependencies # == new version, pins maxstack-runtime
npm view maxstack-runtime versionRuntime first, always: the CLI pins the runtime exactly, so a CLI on the registry without its runtime is uninstallable for everyone.
The clean-dir smoke test the workflow runs is the condensed form of this, which is what catches deep transitive deps and HUB_ROOT/template-resolution regressions — worth running by hand when you touch the packaging:
TEST=$(mktemp -d) && cd "$TEST" && npm init -y >/dev/null
npm install <maxstack>/dist-npm/maxstack-runtime-*.tgz <maxstack>/dist-npm/maxstack-*.tgz
./node_modules/.bin/maxstack --version # prints the new version
./node_modules/.bin/maxstack init demo && cd demo
../node_modules/.bin/maxstack demo # bundled seeder runs on plain node
PORT=3457 ../node_modules/.bin/maxstack dev & # prebuilt server; curl /admin → 200
../node_modules/.bin/maxstack build --vendor-only # snapshot vendors, lockfile restored
cd "$TEST" && npm install # re-resolve: prunes any orphaned peer
./node_modules/.bin/maxstack init demo2 # must still work — see #348 abovedist/ and dist-npm/ are gitignored (build artifacts); the only files a
release commit touches are the two version sites, and pnpm release writes and
commits both.
The changelog #
CHANGELOG.md is generated, not edited: stage-npm.ts rebuilds it from the
chore(release): commits at release time and ships it inside both packages.
Editing it by hand is pointless — the next release overwrites your edit.
Everything released before this repository was made public (≤ 0.11.11) was cut
from a different repo, so those commits are not in this graph and cannot be
regenerated. They are frozen in CHANGELOG.archive.md, which the generator
appends verbatim below the sections it produces. That file is append-nothing,
edit-never; it exists so that regenerating cannot silently delete history.