Skip to content

v0.11.0

Choose a tag to compare

@github-actions github-actions released this 27 Jul 13:10

Breaking

  • buildCollection and buildProperty are removed — not deprecated, removed. Both were FireCMS-migration shims that had been superseded by defineCollection, and keeping a deprecated alias around in a framework that has not shipped 1.0 only buys two ways to write the same thing.

    buildCollection was 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. defineCollection uses a const type parameter to capture the literal, which is what puts your property keys into completion for admin.titleProperty, admin.sort and admin.propertiesOrder. buildProperty wrapped 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.

  • CollectionConfig reports Postgres in its type errorsCollectionConfig is a union discriminated on engine, and Postgres collections omit engine because it defaults to "postgres". An incomplete Postgres literal therefore matched no member, and TypeScript elaborated the failure against the last constituent — MongoDB. Leaving out name, the most common mistake there is, told a Postgres user of a Postgres-first framework that they were missing engine on a MongoDBCollectionConfig. Postgres is now last in the union, so the same mistake names PostgresCollectionConfig and only the field actually missing. No runtime or assignability change; error text only.

  • Admin-panel presentation moved into an admin block — 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 from collections.ts — so a backend that never renders anything still pulled the React layer into its type graph, and @rebasepro/types could not be a backend contract while it depended on React.

    @rebasepro/types is now the React-free BaaS contract; the presentation layer lives in a new @rebasepro/admin-types that depends on it, and nothing in core depends back. pnpm check:baas-types typechecks a full BaaS project — backend, driver, collection file, SDK reads and writes — with react mapped 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_tags is a table, whether author_id is a column, or whether a joinPath actually 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, so posts/1/tags answered [] — 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.table is 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 EXISTS subquery 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-tautology is critical on Rebase and PostgREST but only low on Supabase, where auth.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/:childId links 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.

  • geopoint and binary are real field types in the admin panel — both were in the property model with nothing behind them. geopoint was 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. binary resolved to the plain text field, which offers multiline, markdown and email (none of which mean anything for bytes) and whose editor merges type: "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 finite 0 and 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_input joins 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'srebase build now emits dist-bundle/: compiled collections, functions, crons and schema plus a generated manifest.json recording the runtime range it needs, a schemaVersion hash, its declared dependencies, and whether it uses native modules. @rebasepro/server boots it (bootFromBundle, bin rebase-server), and docker/server.Dockerfile publishes 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.yml is that, ready to run.

    A repo-root rebase.json declares topology only — the runtime compatibility range and the apps this repository contributes (backend, static, admin, mobile). Schema, rules, hooks and functions stay TypeScript in config/, which is the point of the product and does not move into JSON. rebase link accepts 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 projectGET /api/meta/contract (admin, service-key or admin API-key gated; fail-closed 404 when no auth is configured) serves the collection contract, and rebase generate-sdk --from <link|url> reads it instead of importing local config/. 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 the schemaVersion it was generated against so drift is detectable; GET /api/meta/schema-version is 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 500 on 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 deliberate rebase db push, with its dry-run and confirmation gate. none opts out.

  • Storage authorization can look up ownershipstorageAuthorize received 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's config package, which depends on @rebasepro/types alone and cannot resolve @rebasepro/server at 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 / storageSources as 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/metrics in Prometheus text format, off unless REBASE_METRICS=true and gated by REBASE_METRICS_TOKEN: request counts and latency histograms per surface, plus process heap, RSS and uptime. Self-hosters can scrape it directly.

  • rebase build folds a single static app into the backend bundle — the runtime already served a SPA from entry.static; nothing put the assets there. So a project whose container served its site at / and its API at /api lost 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-static opts 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, and findById answers for a row only ever seen inside a find. 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 carries fromCache, hasPendingWrites and partial, so an interface can say what it is showing. Tabs share the local database and the outbox over a BroadcastChannel, and only one replays the queue at a time. client.offline gained status() and onStatusChange() for a sync indicator, and isOfflineError() 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. createMany is not keyed, only create. Where
    navigator.locks is unavailable two tabs may both replay the queue.

    See Offline & Local-First Sync.

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/* answers 501 STORAGE_NOT_CONFIGURED with the fix in the message, and everything else — data, auth, realtime — keeps serving. 501 and not 503, so the client's offline queue does not retry uploads that can never land.

    The scaffolded backend matches: it configures S3 for STORAGE_TYPE=s3 and now GCS for STORAGE_TYPE=gcs, and falls back to local disk only outside production (or with FORCE_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 pull wrote 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, tsc checks the generator rather than the code it prints, and the existing tests asserted with toContain, which passes happily on a field the type no longer has. So introspection went on writing cardinality, direction and a top-level target long after Relation stopped 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 through and 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) and Direction (owning/inverse) selects. Both had been pointed at relation.kind without the controls being rethought, so their options went on writing the old vocabulary: choosing "One (has-one)" set kind: "one", choosing "Owning" set kind: "owning". Both also rendered from value={kind} while comparing against "one", so a belongsTo relation 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 a localKey — 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 empty joinPath that joins nothing; and the relations table declared five header cells while rendering four, so kind appeared under a "Cardinality" heading and "Direction" had no cell at all.

    The JSON path was never affected — validateCollectionJson checks kind against 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 relationtarget is a () => CollectionConfig thunk, 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 with target still a string, while every consumer in the codebase calls target(). The cast to Property at the end of that function erased the difference, so it compiled and shipped. Serialization is now switched on kind and assigned without a cast, and fromSerializableCollectionConfigs rebuilds 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 relationName straight off the authored relations array. 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 untyped object. To-one relations are left out: posts/1/author resolves, but documenting it as a paginated list describes a response the client never gets.

  • A custom Field or Preview attached as a lazy import rendered nothing — the documented way to attach one is admin: { 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 both import(...) and the require(...) that CommonJS transforms produce.

  • rebase dev could 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 to server.listen(port, host, cb), and that form registers the handler as a one-shot listening listener 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 announced http://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 PORT in .env had 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, then PORT, then affinity).

  • A bundle build said nothing about ignoring backend/src/index.tsrebase dev runs 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 build and deploy --bundle now name the file, say it is neither compiled nor shipped, and give the two ways forward: move the routes into backend/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 deploy with 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 --source deploy 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 --bundle as what was meant; --source . and the new --force both eject deliberately, and an explicit --source deploy of a managed project warns before it does.

  • deploy --bundle could not skip type checkingrebase build has --skip-type-check and buildBundle already accepted the option; only the deploy argument spec lacked it, so iterating meant building by hand and then pointing --bundle-dir at the result. The flag is accepted on deploy now 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.yml had 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 broken rebase 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.yml that 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.1 rather than localhost, which resolves to ::1 first on macOS while the server binds 0.0.0.0.

  • The CLI init e2e leaked its frontend. rebase dev supervises 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's tsx watch server gets a new pid whenever it restarts, so "any new listener" would have been a way to kill it).

  • rebase cloud link was broken from a fresh checkout — three prompts still used inquirer's removed list type, so running it interactively died with Prompt 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 build produced bundles that could not boot — TypeScript emits import specifiers untouched, so a project on moduleResolution: "bundler" compiled from "./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.