v0.11.0
Breaking
-
buildCollectionandbuildPropertyare removed — not deprecated, removed. Both were FireCMS-migration shims that had been superseded bydefineCollection, and keeping a deprecated alias around in a framework that has not shipped 1.0 only buys two ways to write the same thing.buildCollectionwas a plain identity function whose generic had to be supplied by hand, so it gave up the property inference that is the entire reason to wrap a collection literal at all.defineCollectionuses aconsttype parameter to capture the literal, which is what puts your property keys into completion foradmin.titleProperty,admin.sortandadmin.propertiesOrder.buildPropertywrapped a single property in a conditional type that resolved to the type the property already had — a no-op once the surrounding collection is inferred. -
import { buildCollection, buildProperty } from "@rebasepro/common";
-
import { defineCollection } from "@rebasepro/admin-types";
- export default buildCollection({
-
export default defineCollection({
name: "Posts",
slug: "posts",
table: "posts",
-
properties: { title: buildProperty({ name: "Title", type: "string" }) }
-
properties: { title: { name: "Title", type: "string" } }
});
A plain `const posts: CollectionConfig = { … }` annotation still works and is still typechecked — it just infers nothing, so prefer `defineCollection` in new code. The scaffold templates and every docs example now use it.
- **`where` and `orderBy` are now checked against the row type** — `FindParams` was not generic, so its `where` was `FilterValues<string>` and its `orderBy` an untyped `OrderByTuple`. Passing a generated `Database` to `createRebaseClient` typed the *rows* correctly but not the *query*: `find({ where: { nonexistent_column: ["==", 1] } })` compiled, then came back as a 400 from the API — or matched nothing at all, which is worse. `FindParams<M>` now carries the row type, and a column that does not exist is a compile error.
A dotted path (`"meta.tag"`) still works for reaching into a `map`/jsonb column; its **root** must be a real column. `include` is unchanged — relation names come from `relations`, not from the row type, so nothing in `Database` can check them.
`M` defaults to `Record<string, unknown>` all the way through, so an untyped `createRebaseClient()` behaves exactly as before. The chain that has to stay intact is `createRebaseClient<DB>` → `SDKCollectionClient<M>` → `FindParams<M>` → `FilterValues<FieldPath<M>>`; a non-generic alias anywhere along it silently flattens `M` back to the default, which is precisely how the re-export in `client/src/transport.ts` (`export type FindParams = TypesFindParams`) hid this. `e2e/baas-typecheck/src/sdk.ts` now pins it with `@ts-expect-error`, so `pnpm check:baas-types` fails if the check ever comes back off.
The fluent builder is unaffected: `.where("status", "==", "draft")` was already typed on its parameters. Its internal accumulator stays keyed by `string`, because a `Partial<Record<FieldPath<M>, …>>` is read-only under a generic `M` (TS2862) and cannot be built up in place.
- **The `admin` block's key fields are now checked against the collection's properties** — `titleProperty`, `sort`, `propertiesOrder` and `listProperties` reject a name that is not one of your properties. Previously they accepted any string, so a removed or misspelled field was found by noticing a column had quietly vanished from the panel.
The cause was one line. `augment.ts` merged the block on as `admin?: AdminCollectionOptions` with **no type arguments**, so `M` fell back to its default `Record<string, unknown>`, `Extract<keyof M, string>` widened to `string`, and every key-shaped field accepted anything. `defineCollection` computed the property-key inference correctly the whole time; it was dropped at that seam, one line short of the field that needed it. The completion those fields' docs promised had therefore never worked.
Three non-property forms are still accepted: a dotted path into a `map` property (`"profile.displayName"` — the root is checked, the path below it is not), a child-collection column (`"subcollection:orders"`), and an `additionalFields` key. That last one needs an explicit cast, because `AdditionalFieldDelegate.key` is a plain `string` and nothing carries those keys into the type:
```diff
+ import type { AdditionalFieldKey } from "@rebasepro/admin-types";
- propertiesOrder: ["title", "score"]
+ propertiesOrder: ["title", "score" as AdditionalFieldKey]
Only defineCollection turns the check on — it is what supplies M. A plain const x: PostgresCollectionConfig = { … } annotation infers nothing, so these fields stay permissive there, exactly as before. A type-level test in packages/admin-types/test/admin_collection.test.ts now pins all four fields with @ts-expect-error, so the seam cannot reopen without a build failure.
-
CollectionConfigreports Postgres in its type errors —CollectionConfigis a union discriminated onengine, and Postgres collections omitenginebecause it defaults to"postgres". An incomplete Postgres literal therefore matched no member, and TypeScript elaborated the failure against the last constituent — MongoDB. Leaving outname, the most common mistake there is, told a Postgres user of a Postgres-first framework that they were missingengineon aMongoDBCollectionConfig. Postgres is now last in the union, so the same mistake namesPostgresCollectionConfigand only the field actually missing. No runtime or assignability change; error text only. -
Admin-panel presentation moved into an
adminblock — a collection carried two unrelated concerns in one flat object: what the data is (table, schema, properties, relations, validation, security rules, callbacks) and how an admin panel should draw it (icon,group,listProperties,kanban, entity views, selection controllers, …). Ninety-five fields of the second kind sat beside the first, and twelve React view-model types were exported fromcollections.ts— so a backend that never renders anything still pulled the React layer into its type graph, and@rebasepro/typescould not be a backend contract while it depended on React.@rebasepro/typesis now the React-free BaaS contract; the presentation layer lives in a new@rebasepro/admin-typesthat depends on it, and nothing in core depends back.pnpm check:baas-typestypechecks a full BaaS project — backend, driver, collection file, SDK reads and writes — withreactmapped to a stub, which is the invariant that keeps it that way.What to change. Move presentation fields into
admin:
export default {
slug: "posts",
table: "posts",
- icon: "FileText",
- group: "Content",
- propertiesOrder: ["id", "title"],
- sort: ["updatedAt", "desc"],
properties: { /* … */ },
- admin: {
-
icon: "FileText", -
group: "Content", -
propertiesOrder: ["id", "title"], -
sort: ["updatedAt", "desc"] - }
};
The backend loads the block and never reads inside it, so a project with no admin panel can drop these fields entirely. For completion and checking inside `admin`, author with `defineCollection` from `@rebasepro/admin-types` — it captures the property literals, so `admin.titleProperty`, `admin.sort` and `admin.propertiesOrder` complete over your own property keys instead of `string`.
- **A relation declares a `kind`, and carries only the fields that kind uses** — a relation was one open interface with every join field optional at once: `cardinality`, `direction`, `localKey`, `foreignKeyOnTarget`, `through`, `joinPath`, `inverseRelationName`. Nothing stopped you combining fields that cannot coexist, so the type accepted several relations that could not work — and two of them corrupted data rather than erroring. `cardinality: "many"` with a `localKey` wrote the foreign key onto the *parent* row, because a to-many has no single row to point at; a many-to-many carrying `foreignKeyOnTarget` claimed a column on the target that the junction table owns. Both compiled, and both were shipped.
`Relation` is now a closed union discriminated on `kind`, and the link moves under a `relation` field on the property:
```diff
author: {
name: "Author",
type: "relation",
- target: () => usersCollection,
- cardinality: "one",
- direction: "owning",
- localKey: "author_id"
+ relation: {
+ kind: "belongsTo",
+ target: () => usersCollection,
+ localKey: "author_id"
+ }
}
The five kinds, and where each keeps its key: belongsTo (one row, key on this table, localKey), hasOne / hasMany (one or many rows, key on the target, foreignKeyOnTarget), manyToMany (many rows through a junction, through), and via (reached by joining across several tables, joinPath). Offering a field its kind does not own is now a compile error, so the two corrupting shapes above are unrepresentable rather than merely discouraged.
via is the only kind that still states a cardinality, because a join chain cannot imply one, and it is read-only — Rebase will not guess which hop of a chain a write belongs to. direction is gone: which side holds the key is what the kind says. inverseRelationName is gone with it; the schema generator finds the counterpart by scanning the target's relations.
scripts/codemod/relations-tagged-union.mjs migrates a codebase — it rewrote 232 declarations across 46 files here. It refuses to guess: anything it cannot decide is marked kind: "AMBIGUOUS" for you to resolve, rather than being given a plausible default.
Internally this splits the authored surface from a resolved form. Every consumer now reads a ResolvedRelation with defaults already filled in and writable / shared decided once, instead of each site re-deriving them from optional fields — which is how the write guard and the admin had drifted into disagreeing about whether a via could be written through.
-
A relation whose names do not exist now fails at boot instead of returning nothing — the union settles a relation's shape; it cannot know whether
posts_tagsis a table, whetherauthor_idis a column, or whether ajoinPathactually connects the tables it names. Those are facts about the database. Nothing checked them until a query ran, and the failures were the quiet kind: a missing junction table logged a warning and returned no rows, soposts/1/tagsanswered[]— the same answer a correct relation gives for a post with no tags. The tab rendered, the tab was empty, and nothing said why.The registry now validates every resolved relation against the schema it will run on and refuses to start if any of them cannot resolve, listing all of them at once with the columns actually available and the edit that fixes each. Fatal rather than a warning deliberately: a server that will not boot costs a minute, and a relation that quietly answers "nothing" costs however long it takes someone to notice their data is missing.
It fails open wherever it cannot see enough to be sure — a collection with no registered table, a target belonging to another backend — because blocking boot on a working project is worse than missing one bad relation. The sharpest case it catches is the junction default:
through.tableis derived from the two table names sorted and joined, so renaming a table silently re-points the relation at a name that was never created.
Added
-
rebase-rls-check— audit row-level security on any Postgres — a standalone, read-only CLI that reads a database's catalog and reports what is actually exposed. It runs against any Postgres — Supabase, Neon, RDS, a self-managed server — and needs no Rebase project, which is the point: it has to be worth running for someone who will never adopt the framework.Fourteen checks, three of them taken straight from bugs this codebase shipped and debugged: a bare column inside an
EXISTSsubquery binding to the inner table, junction tables left open while both endpoints were locked, and RLS enabled with no policies serving an empty collection for weeks.Two constraints the design treats as non-negotiable. False positives are worse than misses — checks that cannot see intent are marked heuristic, rendered separately and phrased as questions, and severity is calibrated per platform (
policy-anonymous-tautologyis critical on Rebase and PostgREST but only low on Supabase, whereauth.uid()genuinely returns NULL for anonymous callers, so flagging it there would fire on nearly every Supabase database alive). And credentials never surface — the connection string is redacted everywhere including the auth-failure path, and the redactor refuses to guess when an unencoded@or/makes the authority boundary ambiguous rather than printing part of a password as a host.See RLS Check.
-
Existing rows can be attached to a many-to-many tab — a junction-backed relation reads as set membership on write:
PUT parent/:id/child/:childIdlinks a row idempotently. Previously the junction row was written only alongside an insert, so a linked tab could create new rows and never attach one that already existed. Unlike an owning foreign key this takes the row from nobody — its other parents keep it — which is why linking is safe here where reparenting would not be. The admin surfaces it as Add existing on a linked tab, opening the picker over the whole target collection. -
geopointandbinaryare real field types in the admin panel — both were in the property model with nothing behind them.geopointwas missing from the widget lookup altogether, so it resolved to no field: the column never rendered on a form, and its property dialog opened showing a name, a description and no type-specific settings — indistinguishable from a property that has none.binaryresolved to the plain text field, which offers multiline, markdown and email (none of which mean anything for bytes) and whose editor mergestype: "string", so touching a binary property's widget silently changed its type.Both now have a field binding, a widget config and a place in the property picker. Geopoint is two coordinate inputs rather than a map, because a map needs a tile provider, an API key and a network, none of which belong in a field that has to work offline; it holds a half-typed location rather than committing it, since sending the empty side through
Number("")yields a perfectly finite0and would drop the point in the Gulf of Guinea. Binary shows a collapsed card with the decoded size and expands only when someone wants to edit the base64.vector_inputjoins them in the picker. It had a binding and an editor already and was simply never listed, so a vector property rendered correctly once it existed but could only be created by writing code. -
A project is a bundle, and the runtime is the platform's —
rebase buildnow emitsdist-bundle/: compiled collections, functions, crons and schema plus a generatedmanifest.jsonrecording the runtime range it needs, aschemaVersionhash, its declared dependencies, and whether it uses native modules.@rebasepro/serverboots it (bootFromBundle, binrebase-server), anddocker/server.Dockerfilepublishes that as an image. The consequence is the point: the engine can be replaced under a project without rebuilding it — upgrading is a new image tag against the same bundle — and self-hosting becomes "run the image with your bundle" rather than "build and maintain your own container".docker/docker-compose.selfhost.ymlis that, ready to run.A repo-root
rebase.jsondeclares topology only — the runtime compatibility range and the apps this repository contributes (backend,static,admin,mobile). Schema, rules, hooks and functions stay TypeScript inconfig/, which is the point of the product and does not move into JSON.rebase linkaccepts a self-hosted base URL wherever it accepts a cloud project, and writes an uncommitted.rebase/cloud.json, because a project reference is per-checkout. -
Remote SDK generation from a running project —
GET /api/meta/contract(admin, service-key or admin API-key gated; fail-closed 404 when no auth is configured) serves the collection contract, andrebase generate-sdk --from <link|url>reads it instead of importing localconfig/. A second repository can therefore build a typed client against a backend it does not contain, which is what makes the multi-repo case work at all. The SDK records theschemaVersionit was generated against so drift is detectable;GET /api/meta/schema-versionis deliberately unauthenticated and returns only that hash. -
Collection tables are created at boot, additively — the runtime ensured its auth tables and nothing ensured the project's, so a backend booted against a fresh database answered sign-in and then
500on every data route.REBASE_MIGRATE_ON_BOOT=ensure(the default) now creates missing tables, columns and enum types before serving. Additive only, permanently: it never drops, narrows or rewrites, so it is safe to run unattended on every start and re-running is a no-op. A removed field leaves its column behind and a rename reads as an addition — destructive changes stay a deliberaterebase db push, with its dry-run and confirmation gate.noneopts out. -
Storage authorization can look up ownership —
storageAuthorizereceived a key, a bucket, an operation and a user, and no way to answer the only question that matters: who owns this object? Ownership lives in a row, so a hook limited to prefix arithmetic on the key expresses no real multi-tenant rule — and it could not fetch that row itself, because the hook is declared in the project'sconfigpackage, which depends on@rebasepro/typesalone and cannot resolve@rebasepro/serverat runtime. The context now carries a trusted, read-only, RLS-bypassing reader (ctx.data). It bypasses RLS deliberately: the hook is the authorization decision, so making it decide through a reader already narrowed by the caller's permissions is circular. -
Multiple data and storage sources — declare
dataSources/storageSourcesas exports of the config package and configure each by suffixing its env var with the source key:DATABASE_URL__ANALYTICS,S3_BUCKET__MEDIA. Two underscores, because one collides with real variable names (S3_BUCKET_NAME). A source that is declared but not configured fails boot rather than silently falling through to the default database. -
Prometheus metrics —
/metricsin Prometheus text format, off unlessREBASE_METRICS=trueand gated byREBASE_METRICS_TOKEN: request counts and latency histograms per surface, plus process heap, RSS and uptime. Self-hosters can scrape it directly. -
rebase buildfolds a single static app into the backend bundle — the runtime already served a SPA fromentry.static; nothing put the assets there. So a project whose container served its site at/and its API at/apilost the site when it moved to a platform-run runtime: the API answered and every page 404'd. The frontend now travels in the bundle and one runtime serves both, which is the shape the scaffolded template produces.--no-staticopts out. -
Local-first sync in the client SDK (
offline: true) — the data layer keeps a normalized local database of rows rather than a cache of responses, and answers queries against it. A row written offline therefore appears in every filtered list it belongs to (filters, sorting and pagination are evaluated locally), a row edited in one view updates in all of them, andfindByIdanswers for a row only ever seen inside afind. Server responses merge into that database instead of replacing it, so a row carrying unsynced local writes keeps them — the user's own change never flickers away underneath them.Writes are decided locally: once the client knows the connection is gone it stops attempting requests, so an offline write costs nothing instead of a timeout, and it applies immediately and replays in order when connectivity returns. A write the server rejects is rolled back, along with the queued edits that were built on it — but not a later create or delete for the same row, which stands on its own. Temporary failures (429, 503, a dropped connection) are retried on an exponential backoff instead, up to
maxRetries.observe()/observeById()are the new reactive reads, on every collection client: local-first, de-duplicated, and re-emitted on any local write, replay, rollback, realtime event, or change from another tab. Each result carriesfromCache,hasPendingWritesandpartial, so an interface can say what it is showing. Tabs share the local database and the outbox over aBroadcastChannel, and only one replays the queue at a time.client.offlinegainedstatus()andonStatusChange()for a sync indicator, andisOfflineError()distinguishes "offline with nothing local to answer with" from a request that genuinely failed.A replayed write is recognised rather than repeated. The queue names each
mutation with an idempotency key, and the server records what that key
answered, so a create whose response was lost to a dropped connection comes
back with the row it already made instead of inserting a second one — the case
the client cannot detect for itself on a table with a server-assigned id,
which is what the scaffold's collections use. Keys are scoped to the
authenticated user and honoured for 24 hours; a backend that cannot store them
ignores the header rather than refusing the write, and auth signups are
excluded because their response can carry a temporary password.Writes made while another is in flight are safe too: an edit is no longer
folded into a request already on the wire (where it was dropped, unsent, when
that request was acknowledged), a delete no longer cancels out a create the
server is in the middle of reading, and an update or delete now queues behind
a pending write for the same row instead of racing ahead of it to a server
that has not seen the row yet.Not yet: conflicting concurrent edits are still last-write-wins — there is
no row version, so two clients editing the same row overwrite each other with
no conflict reported.createManyis not keyed, onlycreate. Where
navigator.locksis unavailable two tabs may both replay the queue.
Changed
-
No bucket means no file storage, rather than a crash or a disappearing disk — 0.10.0 made a production backend refuse to boot on
type: "local", which stopped the silent data loss but replaced it with a crash-looping rollout for anyone who simply had not configured storage — a project that never uploads a file was taken down by a feature it does not use. Storage is now opt-in instead: with no bucket configured in production, no storage backend is registered,/api/storage/*answers501 STORAGE_NOT_CONFIGUREDwith the fix in the message, and everything else — data, auth, realtime — keeps serving.501and not503, so the client's offline queue does not retry uploads that can never land.The scaffolded backend matches: it configures S3 for
STORAGE_TYPE=s3and now GCS forSTORAGE_TYPE=gcs, and falls back to local disk only outside production (or withFORCE_LOCAL_STORAGE=true, for a deployment with a real volume mounted). A named backend that is local-in-production is dropped from a multi-backend map without taking the durable ones with it.
Fixed
-
rebase db pullwrote collection files that would not compile — introspection emits collection source code as template strings, which put it outside every check the relations refactor relied on: the codemod rewrites real declarations and never saw these,tscchecks the generator rather than the code it prints, and the existing tests asserted withtoContain, which passes happily on a field the type no longer has. So introspection went on writingcardinality,directionand a top-leveltargetlong afterRelationstopped accepting any of them.Fixed at every emission site, and the many-to-many case got simpler rather than merely renamed: with no owning and inverse side to choose between, it no longer guesses one from table-name ordering, and no longer hands the losing side a relation with no
throughand a comment asking the reader to finish it by hand. Introspection knows both junction columns already; each side now names them from its own end. -
The relation editor wrote kinds that do not exist — the relation property form still carried its pre-union
Cardinality(one/many) andDirection(owning/inverse) selects. Both had been pointed atrelation.kindwithout the controls being rethought, so their options went on writing the old vocabulary: choosing "One (has-one)" setkind: "one", choosing "Owning" setkind: "owning". Both also rendered fromvalue={kind}while comparing against"one", so abelongsTorelation displayed as "Many (has-many)" and "Inverse" at once — the form disagreed with itself, disagreed with the stored value, and offered no way to pick a real kind.It is one Kind select now, driven by a table shared with the relations tab so the two surfaces cannot describe the same thing differently, and typed so a sixth kind cannot be added to the union without failing to compile there. Three more in the same dialog: saving cast the draft straight to a
Relation, so a junction table filled in and then abandoned by switching to "Belongs to" was persisted alongside alocalKey— exactly the shape the union exists to forbid, smuggled past it by a cast; picking "Via" offered no way to enter a join path while Save stayed enabled, producing a relation with an emptyjoinPaththat joins nothing; and the relations table declared five header cells while rendering four, sokindappeared under a "Cardinality" heading and "Direction" had no cell at all.The JSON path was never affected —
validateCollectionJsoncheckskindagainst the union and rejects fields a kind does not own. Only the form drifted, because nothing typechecks a select's option values against what its handler writes. -
The collection editor could not round-trip a relation —
targetis a() => CollectionConfigthunk, which cannot be written to JSON, so it travels as a collection slug. Nothing rebuilt it on the way back: the deserializer had no branch for relations, so one fell through to the pass-through default and returned withtargetstill a string, while every consumer in the codebase callstarget(). The cast toPropertyat the end of that function erased the difference, so it compiled and shipped. Serialization is now switched onkindand assigned without a cast, andfromSerializableCollectionConfigsrebuilds the thunks against the whole set — resolving lazily, so collections may reference each other in any order. -
Generated OpenAPI documented none of a collection's subcollections — the spec read
relationNamestraight off the authoredrelationsarray. That name is optional and defaults to the property key or the target's slug, so every relation relying on the default was skipped, and relations declared inline on a property were never seen at all, since they are not in that array. A collection could show three subcollection tabs in the admin panel and document zero. The routes now come from the resolved relations — the same names the nested-path router matches — in a second pass after every component schema exists, which also fixes subcollections whose target appeared later in the array silently degrading to an untypedobject. To-one relations are left out:posts/1/authorresolves, but documenting it as a paginated list describes a response the client never gets. -
A custom
FieldorPreviewattached as a lazy import rendered nothing — the documented way to attach one isadmin: { Preview: () => import("./MyPreview") }. JavaScript names an anonymous function after the property key it is assigned to, so that arrow's name is"Preview", and component detection treated "zero arguments, starts with a capital letter" as proof of a component — which is true of every loader written that way. The thunk went to React as a component, React called it, got a Promise, and rendered nothing: an empty cell with no console error. Detection now leads with what the function does — a dynamic module load in the body outranks the name — and matches bothimport(...)and therequire(...)that CommonJS transforms produce. -
rebase devcould print a URL served by a different process — when the first port was busy, the port-retry helper bound the next one but reported the port it had just failed to bind. It passed its success handler toserver.listen(port, host, cb), and that form registers the handler as a one-shotlisteninglistener which a failed attempt never removes; the next attempt's success then ran both, and the earliest won. So with something already on 3001, the server listened on 3002 and announcedhttp://localhost:3001. Whatever was already there answered normally, out of its own database, and nothing logged a warning.Two consequences are fixed with it. The port file recorded the wrong number as well, and port affinity from that file used to outrank an explicitly requested port — so setting
PORTin.envhad no effect while a file from an earlier run existed. The file now records the bound port and the requested one, affinity applies only when the same port is requested again, and this matches the precedence the CLI already used (--port, thenPORT, then affinity). -
A bundle build said nothing about ignoring
backend/src/index.ts—rebase devruns that file whenever a project has one, so throughout local development it is the server and every route in it works. A bundle has no entrypoint of its own: the runtime boots the bundle and mounts what the manifest points at — the config package, functions, crons and the schema. So a project with custom routes in its entrypoint built clean, deployed green, and answered 404 on every one of them, with the file still sitting in the repository looking exactly like the server.rebase buildanddeploy --bundlenow name the file, say it is neither compiled nor shipped, and give the two ways forward: move the routes intobackend/functions/, or declare the app as"type": "custom"to keep your own entrypoint (which is already what a manifest-less repo carrying one is inferred as). -
rebase cloud deploywith no flags did not say what it was about to build — the bare form uploads nothing. It asks the control plane to rebuild what it already holds: a git checkout, or the newest source archive some earlier--sourcedeploy left in object storage. Both are legitimate and neither is the working directory, so a deploy shipping month-old code was indistinguishable from one shipping today's. It now prints the source first — the repository and branch, or the archive's deployment id and age, with a reminder that--source .is what uploads this directory — and says plainly when the control plane holds neither.On a managed project it was worse than stale. A successful source build sets
runtimeMode: "custom"server-side, so the bare form silently swapped a project off the platform runtime and back onto a container image. That case is now a refusal naming--bundleas what was meant;--source .and the new--forceboth eject deliberately, and an explicit--sourcedeploy of a managed project warns before it does. -
deploy --bundlecould not skip type checking —rebase buildhas--skip-type-checkandbuildBundlealready accepted the option; only the deploy argument spec lacked it, so iterating meant building by hand and then pointing--bundle-dirat the result. The flag is accepted ondeploynow and threaded through.
Testing
-
A stable release now runs the full gate before publishing anything. Publishing was not gated on tests: the canary job ran a build and published, and
publish.ymlhad no dependency on CI at all — the two workflows fired in parallel on the same push, so a release could go out while CI was still running, or after it had already failed. The stable job ran unit tests but no end-to-end suite, which meant the failures those suites exist to catch — a brokenrebase init, RLS not isolating rows — were exactly the ones a green build could not see.The whole gate (type checks, headless/BaaS guards, init-template check, unit tests, and every e2e suite) now lives in a reusable
verify.ymlthat CI and the stable release both call, so the release path cannot drift from the one that runs on every push. A stable release stops before any version bump, tag or publish if any of it fails. Canary is deliberately unchanged: it still publishes on a green build alone. -
The template e2e suite could test a server it had not started. It took the backend's address from the announced banner, which is trustworthy only if the server announces the port it bound — see the fix above. Each backend is now given a port the OS reports as free, and the run fails loudly if the banner disagrees rather than continuing against an unknown server and a database it does not control. It also talks to
127.0.0.1rather thanlocalhost, which resolves to::1first on macOS while the server binds0.0.0.0. -
The CLI init e2e leaked its frontend.
rebase devsupervises a Vite that ends up outside the process group the teardown signals, so a frontend survived every run — one held port 5173 for hours with its project directory already deleted. Teardown now also reaps whatever still holds the dev server's own ports, restricted to processes that were not already listening there when the run began (a developer'stsx watchserver gets a new pid whenever it restarts, so "any new listener" would have been a way to kill it). -
rebase cloud linkwas broken from a fresh checkout — three prompts still used inquirer's removedlisttype, so running it interactively died withPrompt type "list" is not registered. Prompts are only constructed when a command actually asks something, so every non-interactive test passed and CI stayed green while the first command anyone runs did not work. -
rebase buildproduced bundles that could not boot — TypeScript emits import specifiers untouched, so a project onmoduleResolution: "bundler"compiledfrom "./posts"and Node ESM refused it. Specifiers are rewritten after compilation. Bundle tarballs no longer carry macOS extended-attribute headers, which GNU tar warned about once per file on extraction and which buried real errors.