Every @orthacms/* package is released together, at one version, and a
generated app pins each exactly (no caret). An upgrade = one version bump across
the set + orthacms migrate.
The lockstep rule
Several packages must share one copy of a dependency — React contexts, the
design system’s tokens, the plugin contract. Two versions of
@orthacms/design-system means two React contexts and an admin that silently
stops working.
Upgrade the whole set, or none of it
A partial upgrade errors nowhere. npm ls react is what finds it.
Bumping
Install every @orthacms/* package you have at the new version, in one
npm install:
NEW=0.13.1
npm install $(node -e '
const m = require("./package.json");
const names = Object.keys({ ...m.dependencies, ...m.devDependencies })
.filter((n) => n.startsWith("@orthacms/"));
console.log(names.map((n) => n + "@" + process.argv[1]).join(" "));
' "$NEW")
npm ls @orthacms/design-system react # exactly one entry eachAdding a package you did not have (GraphQL, a storage provider, a copilot
backend)? Install it at the set’s version — never @latest alone.
Migrate, forward
npx orthacms migrate- Builds the server, then applies every plugin’s pending migrations in plugin order.
- Run it once, as a release step, before the new version starts — not at boot.
- Forward-only. No
down. Rolling back = the previous app version on a migrated database: fine for additive migrations, not destructive ones. Read the SQL and take a backup first.
Release order:
- Bump the set,
npm install orthacms buildorthacms migrate- Start the new version
- Stop the old one
What each release changed
Read down from your current version. Only changes that need action are listed; the changelog has the rest.
0.13.1
A patch release, with nothing to migrate and nothing to change. The far side of
a single relation — author.articles mirroring article.author, say — is
now listed read-only in the record editor, with no Add and no Remove,
under “Linked from Articles — change it there.” It used to offer both, and the
save then failed with Relation "author.articles" owns no writable links from this side, because each of those links is an article’s own foreign key. The
admin learns this from the schema: GET /api/content-schema now gives every
inverse relation inverse.writable, false for the inverse of a single
relation. The write API is unchanged — a delta on that field is still a 400.
0.13.0
Three migrations, all additive. Nothing in your code has to change; a host that overrides mail copy, or an admin plugin of your own, may want a look at the rows below.
| Change | What to do |
|---|---|
Three migrations. Activity 0002_subject_label adds subject_label to activity_events and backfills it from each row’s meta — the readable name the log’s new search reads. Workspaces 0003_workspace_archived_at adds archived_at, backfilled from updated_at for workspaces already archived. Protection 0004_review_approvals_by_user adds the index Recently approved reads. | Run orthacms migrate before the new version starts. The activity backfill updates every row of the log once, so on a large activity_events it takes a while. |
Self-service password recovery. With a mail provider, the sign-in page offers Forgot it?, and GET / POST /api/auth/password-recovery are public: the POST answers 202 for any address, shares the sign-in routes’ per-client limit and allows three messages an hour per address. It mails the new password_recovery kind. Without a mail provider the POST is a 404 and the page says to ask an admin. | Nothing. If you override the mail copy (templates on the mail plugin’s config — for another language, say), add a password_recovery template too: overrides are partial, so the new kind is sent in the shipped English until you do. If a proxy allow-lists public routes, add /api/auth/password-recovery. See identity. |
One password field. The invitation and reset pages ask for the password once, with Show password and a strength meter; confirmPassword is optional on POST /api/auth/invite/accept and POST /api/auth/reset. GET /api/auth/invite/:token also returns the role and workspaces, never the inviter. | Nothing. A client that still sends confirmPassword keeps working. |
The member page has four tabs. Overview, Workspaces, Sessions & access and Activity (plus Preferences on your own page) replace seven. …/users/:id/general and …/roles redirect to overview, …/sessions to access. | Update bookmarks and any links your own code builds to a member’s tabs. |
Publishing rules is now Publishing. The workspace settings section is labelled Publishing, with “Content types that need a review before they go live.” under it. Its path (settings/protection) and message id (protection.settings.tab) are unchanged. | Update help text or runbooks that name “Publishing rules”. A translation keyed on the id keeps working. |
Insights draws sections as tabs and leads with Needs attention. A new slot, INSIGHTS_SIGNAL_SLOT, holds the lines that ask for work, each with a drill-down panel; Waiting on review (protection) and Images missing alt text (media) moved there from INSIGHTS_WIDGET_SLOT cards. xs is for stat tiles only: the Overview tab sets them side by side in one strip, each a cell with no card. Two cards are retitled: By type and Storage. | A widget of your own at size: 'xs' that is not a stat tile: move it to sm. A count that asks for somebody’s work can now be a line instead of a card — see insights. |
New, optional extension points. WorkspaceSectionItem.takeover(pathname) makes a section the workspace sidebar’s only content where it returns true (the copilot’s chats use it). Workspaces-server’s CONTENT_ENTRY_STATS port feeds the cards’ record counts and is bound by content-server. Identity-server exports PasswordRecoveryRegistry / PasswordRecoveryIssuer and INVITE_WORKSPACE_DIRECTORY; content-admin exports diffRevision, useRevisionDetail, fieldLabel, formatRevisionValue, RelationRefList and MediaRefList; copilot-domain exports permissionPreview. A propose tool may declare previewable. | Nothing. Declare previewable only on a propose tool whose handler writes nothing and fetches nothing — the copilot runs it once before asking, to show the change. See agent tools. |
The Agents view’s chats are the workspace sidebar. The resizable thread column is gone, and with it the copilot.agentsRail panel width. | Nothing. A stored width for it is left unread. |
New routes, all additive: DELETE /api/users/:id/sessions (sign out
everywhere), POST /api/users/invites/batch, GET /api/users/roles,
GET /api/users/:id/sign-in, GET /api/workspaces/:id/archive-impact,
GET /api/protection/queue/approved, GET /api/activity/export (CSV),
GET /api/segments/:id/usage, GET /api/copilot/suggestions, and the Insights
drill-downs …/content/stale/records, …/content/unshipped/records and
…/media/alt/missing. Existing responses gain fields: GET /api/users takes
?role= and returns counts and each member’s lastActiveAt;
GET /api/api-tokens takes ?status= and ?q= and returns createdBy and a
summary; GET /api/activity takes ?q=; PATCH /api/alarms/rules/:id
returns the rescan as scan, and switching a rule back on rescans; findings
carry entryTitle; the review queue’s lines carry fieldsChanged. See
endpoints.
Also new, with nothing to do: members get quick filters, Last active and an
Invite people page that invites several people at once; workspaces are
cards that say what is inside, a three-step create ends on a ready page, and
Archive… says what it touches first; the Content Model page is fields first,
with a kind picker, a field panel with a preview, type templates and a one-page
review; Reviews has Waiting on me, My requests and Recently approved
tabs and a review panel with Approve; the Activity log has a search box, a time
range, an event panel, Export CSV and Retry all; audiences show where
they are used and can be tried against a reader’s tags; alarms name flagged
records by title, switch on and off, and preview Right now while you edit;
Ortha CMS AI opens with suggestions written for the workspace, shows a write’s
change on the permission prompt (now Allow once, Allow for this chat,
Don’t allow), edits skills in a sheet, and keeps collapsed chats as pills;
and the API Tokens page names each token’s creator, flags expiries and offers a
ready curl request when a token is created.
0.12.0
An admin-only release: no migration, no API change, no new setting. Nothing an app or an API client does has to change; an admin plugin of your own may want a look at the rows below.
| Change | What to do |
|---|---|
The entry editor is one form. Relations and media render as fields inside Content, in schema order; the tabs are Content, Access and History (plus any a plugin adds). History absorbs Activity. The old …/:entryId/relations, …/media and …/activity addresses redirect — the first two to the entry, the last to history?show=activity — for one release. | Update bookmarks and any links your own code builds to those three tabs; they stop resolving in a later release. |
ContainerHeader keeps its title on screen. Under a top bar a page’s <h1> and subtitle used to fold to screen-reader-only; now they stay visible, and only the actions move into the bar. The keepSubtitle prop is gone. | Remove keepSubtitle (it is a type error now). A plugin page that drew its own heading beside a ContainerHeader would show two — drop one. |
One page measure. Container’s default body is 1120px wide with a 40px gutter; width="full" is meant for a wide records table only. | Nothing, unless a page of yours relied on the old, wider measure. |
New slots. ENTRY_STATUS_ITEM_SLOT (report a status item — blocking, warning or info — that the editor’s header draws; plugins no longer draw header chips) and ENTRY_TIMELINE_SLOT (add rows to History). HOME_SECTION_SLOT gains the action (a top-bar button) and attention (a HomeAttentionCard under Needs your attention) regions. RightPanelPortal takes overlay and attention. | Optional. An ENTRY_TAB_SLOT tab keeps working — unless its id is relations, media or activity, which are redirect slugs now and drop it. See slots. |
Design-system additions. Badge is a 20px chip with muted and line variants, an xs size and BadgeDot; SegmentedControl has a muted variant; Checkbox draws a dash for indeterminate; SelectionBar, InsetFloating and SearchToolbar’s leading are new. | Nothing. A badge drawn as a pill now has 6px corners — pass a class if you want the pill back. |
Also new, with nothing to do: the record’s title is the editor’s heading, with its status, language and save state on one line under it; the header’s actions read Save draft, Publish or Publish changes, and say Published · up to date when there is nothing to do; ⌘S, ⌘⇧P and ⌘. save, publish and open Properties (⌘S takes over the browser’s own Save page while the editor is open). Properties no longer docks by default: it is a panel over the page, and an editor who wants the column back pins it, once per browser. The ⋯ menu loses Save draft and Save & publish, which are the buttons now. On the records page Trash is a tab at the end of the views, and Export sits in the selection bar. The Content Library opens on an overview of the workspace, the records page shows saved views as tabs with status chips, the Media Library has type chips, a list view and a details panel beside the grid, and Home, webhooks, workspaces, alarms, reviews and workspace settings are redrawn. See the entry editor.
0.11.0
Two migrations, both additive. Nothing in your code has to change.
| Change | What to do |
|---|---|
Copilot usage is recorded. A spend ledger, copilot_usage (copilot migration 0007), gets one row per run whether or not a limit is set, so a quota switched on later sees today’s figures at once. | Run orthacms migrate before the new version starts. |
Panel widths are saved to the user. user_preferences gains a layout column (identity migration 0008_preferences_layout). PUT /api/preferences becomes a partial patch: theme and layout are each optional, and a body naming neither is a 400. | Run orthacms migrate. A client that sends { "theme": … } alone keeps working. |
Optional copilot usage limits. CopilotPlugin’s config takes quotas — userDailyTokens, workspaceDailyTokens, runsPerMinute, maxConcurrentRuns — which the reference server reads from COPILOT_USER_DAILY_TOKENS, COPILOT_WORKSPACE_DAILY_TOKENS, COPILOT_RUNS_PER_MINUTE and COPILOT_MAX_CONCURRENT_RUNS. A refused run is a 429 before the stream opens. | Nothing: each is off until set. A generated app reads them only once you add them to config/copilot.ts — see usage limits. 0 fails at boot; leave a variable empty for no limit. |
Also new, with nothing to do: a save, a version restore and a publish-a-version in the admin that would change other languages or other records now always ask first, with a preview of every row they change; the sidebar, the right panel, the Agents rail and the media library’s folders column resize by dragging their edge; pages cross-fade, dialogs and tooltips ease in, and tab indicators slide (none of it under reduced motion); and the Ask Ortha AI launcher and the Agents switch drop their count, showing only a dot while a chat waits for your answer. A translation created after other records linked its group now fills their mirrored links in its language, instead of waiting for their next save, and a link added or removed from the inverse side of a synced many-relation now reaches the owner’s other languages, as a save of the owner would.
For API clients: askBeforeSiblingSync is read on a create and on the two
version routes as well as an update, and its 422 now carries changes — each
other row and what changes in it — beside locales and siblings; its
message is reworded, so branch on code. See
errors.
createAdmin takes a new preloadPages option (default true): once the admin
is idle it fetches every page’s code in the background, so a first visit does
not flash a skeleton. A newly generated app’s apps/admin/src/main.tsx passes
preloadPages: !import.meta.env.DEV, so the dev server does not transform the
whole admin on every reload — add that line to an existing app if its dev
server feels slower after the upgrade.
0.10.3
A patch release, with nothing to migrate and nothing to change. A save in the
admin that reaches other languages — a shared field, or a relation synced across
locales — now asks once, naming every language it changes and any published
one it would leave incomplete; it used to ask twice. A record with no other
translations no longer asks at all. The admin gets that answer from the server
with a new opt-in flag on PATCH /api/content/:type/:id,
askBeforeSiblingSync, which refuses the save with a 422
code: "content.sibling_sync_unconfirmed" and writes nothing. A client that
never sends the flag sees no difference.
0.10.2
A patch release, with nothing to migrate and nothing to change. One response
gains detail: a save refused because it would leave a published translation
failing validation is still a 422, now with
code: "content.sibling_invalidated" and every affected locale in
siblings: [{ locale, issues }] — so a client can tell those issues are not
about the row it sent. In the admin the same save asks first and offers Save
anyway. Delete confirmations now say what links to the entry, a link to a
record in the trash shows that record’s title with an In trash mark, and
trashing one translation of a record no longer unlinks it from the other
languages on their next save.
0.10.1
A patch release, with nothing to migrate and nothing to change — every change is in the admin. A row in the Agents rail now marks a thread that is working (a spinner) or waiting for your answer (an amber dot). Switching an entry’s language happens at once: the cover that used to hold the page for the better part of a second is gone, and opening the locale menu prefetches the saved translations. In the schema builder, a select’s options move from Validation to General, above the default that is picked from them; groups are dragged into order by their own handle and edited from their own menu; and a list makes room for a field while it is still being dragged.
0.10.0
One migration, and one change for anyone who wrote their own storage provider.
| Change | What to do |
|---|---|
Login rate limits are shared. Counts move from process memory into Postgres (throttle_buckets, identity migration 0007), so several instances share one limit instead of each allowing the configured attempts. | Run orthacms migrate before the new version starts. |
Public (CDN) URLs. A storage provider may now report public (CDN) URLs for its objects. StorageProvider.capabilities gains a required publicUrls flag. | A custom provider adds publicUrls: false to its capabilities. Every first-party provider already does. Nothing else changes until you opt in with publicUrls: 'provider' in the media config — off by default, and with it off every URL stays the authorized /raw route. |
Also new: a field can carry a default value that prefills the create form, the locale menu is searchable and lists the default locale first, the sidebar’s Agents switch shows the status of open chats, and the admin and the scaffolded app have a favicon. The copilot’s message limit rises to 32,000 characters and the composer enforces it.
0.9.1
A patch release, with nothing to migrate and nothing to change. The entry editor gains an outline of the General tab’s fields at its edge, and the copilot starts a new chat on the last model you picked. Three fixes change what a reader or an agent sees: the records list’s “Can be seen by” filter now matches who can actually read the entry, an agent reads only the type schema of the workspace it is working in, and the alarm editor offers only the workspace’s own collections.
0.9.0
A redesign of the admin, and nothing to migrate. The work area floats as one white card on the sidebar’s frame, page titles fold into the top bar along with the page’s actions and tabs, list tables run full width without a frame, and “Ask Ortha AI” moves under the work area. An app generated from 0.8.0 already has the light sidebar tokens, so its stylesheet needs no change.
| Change | What to do |
|---|---|
QueryBuilderPanel is removed from @orthacms/query-builder-admin. Filters open in QueryBuilderPopover, anchored to an icon button; useFilterDraft holds the draft, Apply and Reset logic. | Only if a plugin of yours rendered the panel: render QueryBuilderPopover with the same fields, value and onApply. |
InsetBarEnd is now InsetFooterEnd in @orthacms/design-system, and it draws under the work area rather than at the top bar’s end. | Only if a plugin of yours put chrome there: rename the import. |
ContainerHeader folds into the top bar on a desktop page that has one: its title becomes screen-reader-only and its actions render in the bar. Wrap a page’s tabs in TopBarTabs to put them in the bar’s second row; Container width="full" is for a page that is one big table. | Nothing. A plugin page’s header and actions move up on their own; check that its actions read well at the bar’s compact size. |
| Back links a breadcrumb already covers are gone, and list toolbars are compact, with Columns and Filters as icon buttons. | Nothing. |
0.8.0
The schema builder. It adds no migrations and
owns no tables, so orthacms migrate has nothing new to run. The one new
permission, schema:manage, reaches administrators when the new version boots,
because the seeder reconciles the grants. An app generated before 0.8.0 runs
unchanged. It just doesn’t get the Content Model page until you wire it.
| Change | What to do |
|---|---|
Schema builder. The admin’s Content Model page (/content-model) lists every content type to anyone with content:read. In development with SCHEMA_BUILDER=true it also edits them, writing type files and a drizzle-kit migration. | Optional. To get it in an existing app, follow the steps below. |
orthacms content sync regenerates src/content/index.ts, the content manifest, from the files under src/content/collections/ and src/content/pages/. | Nothing, unless you want the builder. It only edits apps whose manifest is generated. |
schema:manage is added to the catalogue (37 keys, admin only). | Nothing. Administrators get it on the next boot. |
Two adjacent half-width fields share a line in the entry editor without admin.row. A half-width field with no half-width neighbour leaves the other column empty. | Nothing. admin.row still works. Check forms where two half fields were deliberately on separate lines. |
| Sheets (the side panels in the admin) slide in. There is no motion under reduced motion. | Nothing. |
Wire the builder into an app generated before 0.8.0:
-
Install both halves at the set’s version, plus drizzle-kit:
npm install @orthacms/schema-builder-server@0.8.0 @orthacms/schema-builder-admin@0.8.0 npm install -D drizzle-kit -
Move your types to the conventional paths. Each file sits at
src/content/collections/<name>.tsorsrc/content/pages/<name>.tsand exports a type named<name>. Then generate the manifest:npx orthacms content sync # writes apps/server/src/content/index.ts -
Register
ContentPlugin({ types: contentTypes, migrations })from the manifest,SchemaBuilderPlugin(config.plugins.schemaBuilder)after content on the server, andSchemaBuilderPlugin()in the admin. Addconfig/schema-builder.tsanddrizzle.config.ts. Snippets are on the plugin page and in migrations. -
Switch the server tsconfig to
"module": "node16"and"moduleResolution": "node16", and excludedrizzle.config.ts. The oldnoderesolution cannot resolve@orthacms/content-server/define. -
Ignore the scratch folder. Add
.orthacmsto.gitignore. -
Hand types over if you want the builder to edit them: put
// @orthacms-generatedon a type file’s first line. Any type without the marker stays read-only on the page.
Leave SCHEMA_BUILDER unset in production. The page is read-only there
whatever the flag says.
0.7.0
Shared workspaces, form layout, and reader tags by header. Three migrations:
workspaces gains is_shared and per-source shared content grants, protection
gains a review-request resolution column — so orthacms migrate is not
optional. Nothing is shared, and no form changes, until you opt in.
| Change | What to do |
|---|---|
Reader tags from X-Reader-Tags by default. A newly generated config/segments.ts passes headerSegmentResolver; the header is renamed with SEGMENTS_READER_TAGS_HEADER. | An app generated before 0.7.0 passes no resolver, so every reader is anonymous. Wire it (snippet below) if you use segments. Send the header only from your backend. |
Shared workspaces. An admin can flag a workspace shared (PATCH /api/workspaces/:id with isShared). A consumer workspace reads its published records only through an explicit shared grant per type (POST /api/workspaces/:id/content with sourceWorkspaceId). Foreign records are read-only everywhere. | Nothing — sharing a workspace exposes nothing until a consumer adds a shared grant. Once one exists, that consumer’s public REST/GraphQL lists and reads include the source’s published records of the type. |
Writing a type held only through a shared grant answers 403 (“This workspace can only use … records from shared workspaces; it cannot create its own.”). The admin hides Save on shared records and shared-only types. | Nothing, unless a client writes to such a type — write in the source workspace. |
MCP content_list and copilot search take source — own (default), shared or all; content_get returns source and readOnly. The admin list takes ?source= and ?sourceWorkspaceId=. | Nothing. Agents asked to edit a shared record get a 403 tool error telling them to link it instead. |
Form layout. collection() / single() take groups (collapsible sections); fields take admin.group, admin.width ('half' or 'full') and admin.row. | Optional. Every field stays full width unless it asks for half. An unknown width, an undeclared group, or a group with no fields fails at define time. |
| Public API serves the published version of a Modified entry. Saving a published entry no longer takes it off REST/GraphQL; its last published version (values and relations) is served until the next publish. | Remove any workaround that republished immediately after every save. A full token with ?status=draft still reads the working copy. |
| A required relation to a type the workspace cannot reach no longer blocks publish; bulk publish now checks required many-relations like single publish. | Nothing — a bulk publish that used to pass may now be refused for an empty required many-relation. |
| The AI launcher moved into the top bar as an icon; the bottom dock gutter is gone. The entry editor’s locale menu is richer (summary strip, per-locale status, scrolls for many locales). | Nothing. Update screenshots and editor guides. |
create-orthacms-app installs on npm 10. | Nothing for an existing app. |
Wire the reader-tag header in an app generated before 0.7.0:
// apps/server/config/segments.ts
import { headerSegmentResolver, type SegmentsPluginConfig } from '@orthacms/segments-server';
import { readEnv } from '@orthacms/utils-server';
export function segmentsConfig(): SegmentsPluginConfig {
return {
resolver: headerSegmentResolver(
readEnv('SEGMENTS_READER_TAGS_HEADER') ?? 'x-reader-tags'
)
};
}curl https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium, eu'Share a library workspace and grant one of its types to a consumer (a session with workspaces:update that is a member of the workspace being changed):
# 1. flag the source as shared
curl -X PATCH -b jar.txt -H 'Content-Type: application/json' \
-d '{"isShared": true}' \
https://cms.example.com/api/workspaces/<library-id>
# 2. let the consumer read the library's "tag" records
curl -X POST -b jar.txt -H 'Content-Type: application/json' \
-d '{"slug": "tag", "sourceWorkspaceId": "<library-id>"}' \
https://cms.example.com/api/workspaces/<consumer-id>/content
# revoke that shared grant (the consumer's own "tag" grant is untouched)
curl -X DELETE -b jar.txt \
"https://cms.example.com/api/workspaces/<consumer-id>/content/tag?source=<library-id>"Or in the admin: the source’s Settings → Sharing, then the consumer’s Settings → Content types. See workspaces.
Form layout in a content type:
export const event = collection('event', {
label: 'Events',
groups: {
schedule: { label: 'Schedule' },
seo: { label: 'SEO', description: 'Search and sharing.', collapsed: true }
},
fields: {
title: field.text({ required: true }),
startsAt: field.datetime({ admin: { group: 'schedule', row: 'when' } }),
endsAt: field.datetime({ admin: { group: 'schedule', row: 'when' } }),
venue: field.text({ admin: { group: 'schedule', width: 'half' } }),
metaTitle: field.text({ admin: { group: 'seo' } })
}
});0.6.0
The ortha → orthacms rename around the packages (the @orthacms/* names
are unchanged). Nothing is aliased — an old name is simply not read. Five
migrations: an outbox pruning index, mail_deliveries, and protection’s tables
(the last two only if you register those plugins).
| Change | What to do |
|---|---|
The CLI is orthacms, not ortha. | Replace ortha build, ortha migrate and ortha start in package.json scripts, Dockerfiles and CI. |
The server reads apps/server/orthacms.config.ts, and its type is OrthaCmsConfig. | Rename ortha.config.ts and every OrthaConfig import. |
ORTHA_* variables are ORTHACMS_*: ORTHACMS_ROOT_ADMIN_EMAIL, ORTHACMS_ROOT_ADMIN_NAME, ORTHACMS_ROOT_ADMIN_PASSWORD. | Rename them in .env and in your secret store. An unread variable is not an error, so a missed one fails quietly. |
The session cookie is orthacms_session. | Nothing — but every signed-in user is signed out once, by the deploy. |
Webhook deliveries carry X-Orthacms-Event, -Delivery, -Event-Id, -Workspace, -Attempt and -Signature, and the User-Agent Orthacms-Webhooks/1. The signature value is unchanged. | Update every receiver to read the new header names before you deploy; a receiver that reads X-Ortha-Signature rejects every delivery, and each rejection is retried and then fails. |
MCP resource URIs are orthacms://content-type/<name>. | Update any client that addresses a type resource by URI. |
The scaffolder is published as create-orthacms-app. | Nothing for an existing app. |
| Delivered outbox rows are pruned after 30 days. Until now nothing removed one. Pending and parked rows are never deleted by age. | Nothing, if 30 days suits you. To change it, pass outboxRetentionDays to DatabasePlugin — an existing app does not read OUTBOX_RETENTION_DAYS until you wire it, the way a newly generated orthacms.config.ts does. 0 keeps everything. |
A parked outbox event can be retried from the activity log, through POST /api/activity/dead-letters/:id/retry, under a new key, activity:manage. | Nothing. Only administrators hold it, and no API-token scope carries it. |
| Mail and publication protection ship as plugins. An existing app does not get either by upgrading, and the scaffolder’s new mail question only wires mail into apps generated from now on. | Install and register them if you want them — see mail and protection. Protection is inert until a rule exists. |
The database name in DATABASE_URL is yours, and nothing reads it: only the
template’s default changed. Keep the database you have.
0.5.2
A create-orthacms-app fix; nothing to do for the upgrade itself. But an app
generated by 0.5.1 or earlier registers WebhooksPlugin() with no config,
so every WEBHOOKS_* key in its .env is silently ignored — including the two
ALLOW flags.
| Change | What to do |
|---|---|
Newly generated apps get a config/ module for webhooks, transfer, segments and GraphQL, and pass each to its plugin. | Nothing for a new app. |
An app generated before 0.5.2 ignores every WEBHOOKS_* key in its own .env. | Add a config/webhooks.ts that reads them and pass it: WebhooksPlugin(config.plugins.webhooks). The quickest way to get the file is to scaffold a throwaway app with 0.5.2 and copy it across. |
| The same app passes no transfer identity map, no reader-tag resolver and no GraphQL cost budget. | Optional — all three have working defaults. Copy those modules over at the same time if you want the settings to have a home. |
0.5.1
Packaging only — a release staged one package without its licence field and one build’s output was not what shipped. Nothing to do beyond the bump.
0.5.0
Migrations for webhooks (three tables), the activity log (actor type, workspace column) and the outbox (retry backoff, last error).
| Change | What to do |
|---|---|
| Webhooks ships as a plugin. An existing app does not get it by upgrading. | Install @orthacms/webhooks-server and @orthacms/webhooks-admin at the same version, then register WebhooksPlugin(config.plugins.webhooks) on the server and WebhooksPlugin() in the admin. See the plugin page. |
| The activity log now records the contexts that used to raise no event at all, and one entry’s history is reachable from the entry. | Nothing, beyond the migration. Existing rows keep their meaning. |
orthacms --version answers, and orthacms --help no longer exits 1. | Nothing. |
0.4.1 – 0.4.3
No breaking changes. 0.4.3 adds tables — alarms, reader segments, saved list
views — so orthacms migrate is not optional. create-orthacms-app fixes in 0.4.1
and 0.4.2 affect only apps scaffolded afterwards.
0.4.0
| Change | What to do |
|---|---|
SESSION_SECRET and TOKEN_SECRET removed from IdentityPlugin’s config. They were read by nothing — sessions and one-time tokens are random values checked against a row. | Delete both from .env and from orthacms.config.ts. A config object that still passes sessionSecret or tokenSecret will not type-check. |
MediaServerPlugin takes one constructed provider, not a providers array. There is no registry, no resolver and no defaultProvider. | Construct the one backend in plugins.ts — createLocalStorageProvider(config), createS3StorageProvider(config), and so on — and pass it as provider. |
Storage backends are their own packages: @orthacms/media-provider-local, -s3, -azure, -gcs, -vercel-blob, and -memory for tests. | Install the one you use, at the same version as the rest. MEDIA_PROVIDER was documented and never existed; remove it. |
Signed-URL direct serve. MEDIA_DIRECT_SERVE=signed-url makes /raw answer a 302 on backends that can sign. | Clients that follow redirects need nothing. curl needs -L. |
The orthacms CLI and npx create-orthacms-app arrive. | Existing apps can adopt @orthacms/cli — orthacms build, migrate, start — but nothing forces it. |
Single sign-on. IdentityPlugin(config, { sso: { providers } }) with session.cookieSameSite: 'strict' refuses to boot. | Use lax if you register an identity provider. |
The transfer plugin ships but a scaffolded plugins.ts does not register it. | Add TransferPlugin server-side and its admin half to get Export and Import. |
0.3.0
| Change | What to do |
|---|---|
richtext values are a structured document, not a string. Rows written before 0.3.0 still hold strings. | Anything reading a rich-text value must accept both — asRichTextDocument and richTextPlainText in @orthacms/content-domain normalise either. |
| A media value carries alt text. | Readers that treated a media field as a bare id should read the expanded media block instead. |
CopilotPlugin drops defaultProvider. The provider list is the setting: the first registered backend serves a run that names none. | Order providers in plugins.ts deliberately, and remove defaultProvider from the config. |
TRUST_PROXY is read by the host. Unset, forwarded headers are ignored. | Set it to the hop count — TRUST_PROXY=1 — on any deployment behind a proxy. Before 0.3.0 the login rate limit was already collapsing into one bucket behind a proxy; now it can be fixed. |
Entry domain events (entry.created, entry.updated, entry.deleted, entry.restored) join the outbox. | Nothing, unless you wrote an outbox subscriber that assumed only publish events existed. |
Adding webhooks to an older app
Bumping does not add the plugin. Install @orthacms/webhooks-server and
@orthacms/webhooks-admin at the set’s version, then:
// apps/server/src/plugins.ts (with a config/webhooks.ts copied from a fresh app)
WebhooksPlugin(config.plugins.webhooks),
// apps/admin/src/plugins.ts
WebhooksPlugin(),and run npx orthacms migrate (three tables).
@orthacms/copilot-provider-fake is no longer published. A deployment that
used it must configure a real backend or keep COPILOT_ENABLED=false.
What to read next
- Deployment — the release order in the context of the whole process, and the reverse proxy.
- Database and migrations — the plugin-order constraint a failed migration usually means.
- Troubleshooting — the symptom a partial upgrade produces, and the others.