Changelog¶
All notable changes to Cmsify are documented in this file.
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
[Unreleased]¶
[0.7.8] - 2026-10-01¶
Fixed¶
- Fixed the changelog's provider-portability link when staging the documentation site, restoring the strict MkDocs build. Added a regression test for the repository-link rewrite; runtime behavior and PostgreSQL defaults are unchanged.
[0.7.7] - 2026-10-01¶
Changed¶
- Isolated database-provider mappings and scheduled-publication locking queries behind infrastructure contracts, keeping Core entities free of provider-specific types. PostgreSQL retains its existing schema, migrations, optimistic concurrency and
FOR UPDATE SKIP LOCKEDbehavior. - Published Docker containers continue to use PostgreSQL. Application registration, connection-string configuration and Docker defaults are unchanged; there is no automatic SQLite selection or fallback.
Added¶
- Experimental SQLite infrastructure mappings for JSON, tags, timestamps and tracked-save concurrency, plus scheduled-publication claim/reclaim and fencing queries. This is an initial portability slice, not complete SQLite deployment support: migrations, deployment registration, other workers, search and bulk-update concurrency still require qualification. See provider portability.
- Provider regression tests cover JSON/tag roundtrips, in-place tag edits, stale-update rejection, expired-lease recovery, stale-token fencing, independent competing connections and an unchanged PostgreSQL migration snapshot. The full solution suite passed 998 tests with no failures or skips before PR #111 was merged.
[0.7.6] - 2026-10-01¶
Fixed¶
- The shipped compose files (
docker-compose.yml,docker-compose.prod.yml) now putpostgres,apiandadminon a privatecmsifynetwork. The API gets acmsify-apialias there, and the admin points athttp://cmsify-api:8080instead ofhttp://api:8080. - The generic name broke as soon as the admin joined a network shared with other stacks, such as an external
backendnetwork used to reach a shared Postgres. Compose registers every service under its own name on every network it joins, and Docker DNS resolves a name across all of the caller's networks. Soapiround-robined across every project'sapicontainer. - The result was intermittent "Invalid email or password" for correct passwords, valid sessions suddenly rejected, and Cmsify credentials and session tokens sent to unrelated services.
- Deployments using their own compose file must apply the same change.
docs/operations.md("Shared Docker networks") explains how, and how to verify it:getent ahosts cmsify-apifrom inside the admin must return exactly one address.
Added¶
- When the API rejects a login, the admin now logs a warning with:
- the status code;
- the ProblemDetails type, title and detail;
- the trace and correlation ids;
- the email;
- the kind of
Authorizationheader that was actually sent (none,opaqueorjwt, read after any delegating handlers).
A rejection from something other than the Cmsify API is therefore visible in the admin log. Tokens are never logged.
- The API logs a warning for every 401 response, with:
- the method and path;
- the correlation id;
- the bearer kind (none, opaque, api-client or jwt);
- the resolved actor and remote IP;
- a best-effort source (RequireRole filter, endpoint, or no endpoint matched).
[0.7.5] - 2026-10-01¶
Security¶
- The audit log no longer stores secrets verbatim.
AuditDeltaBuilderused to write the before and after value of every changed property intoaudit_logs.change_delta, so every password change left raw bcrypt hashes in the audit trail. That covered the seeded admin, change-password, and admin reset.PasswordHash,Secret(webhook HMAC secret) andTokenHashare now recorded asredacted:<first 12 hex chars of SHA-256>. The audit log still shows when a secret changed, who changed it, and whether two values are the same, without exposing the value. - Migration
RedactAuditLogSecretsrewrites existingaudit_logsrows to the same fingerprint. It is irreversible. Back upaudit_logsbefore upgrading if you need the original values.
Added¶
- The API now logs every local login attempt. A failure logs a warning with a reason (
UserNotFound,UserDeleted,UserInactiveorBadPassword), the attempted email, the user id when known, and the remote IP. A success logs at information level. Passwords and hashes are never logged. Every failure still returns the same plain 401, so the response does not reveal which accounts exist. Attempts against unknown accounts are verified against a dummy hash, so they take about as long as real ones. - The admin now handles an expired or revoked API session gracefully. The new
GET /admin-auth/session-expiredendpoint clears the admin cookie and redirects to/login?error=session-expired, keeping a validatedreturnUrl. Any API 401 inside an interactive admin circuit now redirects there once: during navigation, during page load, or from an event handler. - 401s from credential-checking endpoints (
auth/login, andauth/change-passwordwith a wrong current password) are excluded. - Requests sent without a token are excluded.
Changed¶
- New passwords are rejected with
400if they start or end with whitespace, or contain control characters, invisible formatting characters (zero-width space, BOM, direction marks), line or paragraph separators, or any space other than U+0020 (such as a non-breaking space). This applies to change-password, admin reset, user creation, and the seed admin password, where the API now fails fast at startup. Passwords pasted from password managers or rich text could silently pick up such characters, so the stored password differed from the one the user typed later. Login does not apply these rules, so existing passwords keep working.
Fixed¶
- The admin no longer crashes the Blazor circuit (
Unhandled exception rendering component: Unauthorized) when the API session token inside a still-valid admin cookie has expired. This includes the login page itself. Escaped 401s are caught by a boundary, and error toasts are suppressed while the redirect is under way.WorkspaceStateis marked initialized only after a successful load, so a failed load no longer leaves the workspace list stuck empty. - Login now prefers the active user when a soft-deleted user shares the same email. Email is only unique among non-deleted users.
[0.7.4] - 2026-09-24¶
Fixed¶
- Repeatable component fields (e.g. page sections) now keep their order. The editor writes a distinct, increasing
Orderper component instance, the API renumbers repeated values of one field that arrive with equalOrder(preserving submitted sequence), and duplicating a version or reading one uses a deterministic tie-break so order is no longer scrambled when a draft is created from a published version. Pages saved by earlier versions have lost their true order and need one re-save in the intended order.
[0.7.3] - 2026-09-20¶
Added¶
- Content item and version responses (
ContentItemSummaryResponse,ContentItemDetailResponse,ContentVersionDetailResponse) now exposeTemplateSlugalongside the existing display-nameTemplateName, so API consumers can match on the template's stable slug instead of its human-readable, editor-editable name (e.g.TemplateName"Libraries Index" vs.TemplateSlug"libraries-index"). Matching againstTemplateNamehas caused a real production bug for a consuming site.TemplateSlugis a trailing optional parameter (= "") on each record, so existing positional construction by external consumers keeps compiling.
[0.7.2] - 2026-09-19¶
Re-release of 0.7.1: its automated promotion failed partway through (the NuGet preflight check errored before publishing), so no packages or GitHub Release ever went out for that tag. No functional changes from 0.7.1 - see that section below.
[0.7.1] - 2026-09-19¶
Fixed¶
Cmsify.Components'ContentEditPanelrendered zero fields - silently, with no error and no failed request - when editing or read-only-viewing a content item whose pinned template version was no longer any template's current version (reproduced against production content that predated a later template republish and had never been upgraded viaupgrade-template-version).LoadContentAsyncresolved which template's fields to load by scanningGET /templatesfor a template whoseCurrentVersionIdmatched the content version's ownTemplateVersionId; a content item pinned to an older, now-Archived version has no such match, sotemplatecame backnull,LoadTemplateVersionAsyncwas never called, andtemplateVersionsilently kept its hardcoded empty default (Fields: []) - the same code path runs for both edit and read-only display, so both modes rendered identically blank.TemplatesControllergainedGET /api/v1/workspaces/{ws}/templates/versions/{versionId}, which resolves a template version by its own id regardless of which template owns it or whether it's current (additive perdocs/api-compatibility.md);SyntaxCircus.Cmsify.Client.TemplateClientgained a matchingGetVersionByIdAsync(workspaceId, versionId, ct).ContentEditPanel.LoadContentAsyncnow falls back to it whenever the current-version scan finds no match, instead of leaving the form silently empty.
[0.7.0] - 2026-09-18¶
Added¶
POST /api/v1/workspaces/{ws}/content/{id}/versions/{n}/upgrade-template-versioncould never move content onto a target template version that adds a required field - reproduced in production against a "home page" template whose new version added four required fields, permanently blocking the consumer's upgrade-then-update flow. The endpoint remapped each existing value onto the target's field with the same key (dropping values whose key no longer existed), then validated before saving; a newly-added required field has no old key to carry a value over from, so validation always failed with 422validation-failed(Field '…' requires at least 1 value(s).). Nothing else in the API could supply the missing value instead:CreateContentVersionRequesthas no template-version parameter (new versions deliberately follow the item's latest version's template), andUpdateContentVersionRequestcannot change template version - so a caller had no way to complete the upgrade at all.ContentController.UpgradeTemplateVersionnow accepts an optional request body,UpgradeTemplateVersionRequest(IReadOnlyList<ContentFieldValueRequest>? Fields). No body, or a body withFieldsnull, is exactly today's behaviour, byte for byte - the key remap, unchanged - since every existing caller (including the .NET SDK's original overload) sends no body. WhenFieldsis supplied, the endpoint sets the version onto the target template version and calls the existingApplyVersionFieldValuesAsync(the same full-replacement helperUpdateVersionuses) instead of the key remap, then validates the finished result as one atomic step; a validation failure (a missing required field, or a field id not present on the target) returns 422 without callingSaveChangesAsync, so a failed upgrade never half-applies - the version is left exactly where it was. Field ids inFieldsrefer to the TARGET template version's fields. The[FromBody]parameter usesEmptyBodyBehavior.Allowso a genuinely empty request (noContent-Type, zero length - what the existing .NET SDK sends) still binds tonulland succeeds; the generated OpenAPI now marks the bodyrequired: false, additive perdocs/api-compatibility.md.SyntaxCircus.Cmsify.Client.ContentClient.UpgradeTemplateVersionAsynckeeps its existing signature (still posting no body, unchanged for every compiled consumer) and gained a new overload takingIReadOnlyList<ContentFieldValueRequest> fieldsthat postsUpgradeTemplateVersionRequest.
[0.6.2] - 2026-09-16¶
Fixed¶
POST /api/v1/workspaces/{ws}/content/{id}/versionswithDuplicateFromVersionNumberset to a version that had been moved onto a newer template version via.../versions/{n}/upgrade-template-version(and then published) failed with aCmsifyApiException/422validation-failed: "Field value '…' targets a field not present on the template version." repeated for every copied field - reproduced in production against 0.6.1.ContentController.CreateVersionloaded the template version to validate against from the ITEM-levelContentItem.TemplateVersionId, and assigned that same stale id to the new version, before copying the source version's field values and validating them against it.ContentItem.TemplateVersionIdis set once at item creation (Create) and was never updated afterward:UpgradeTemplateVersiononly ever mutated the individual version's ownTemplateVersionId, so the moment any version was upgraded, the item-level field permanently pointed at the original template version while the actual versions moved on - and duplicating from an upgraded (and now-published) version copied field values that belonged to the new template version but validated them against the old one. Every other content-mutation endpoint (UpdateVersion,Publish,UpgradeTemplateVersionitself,ScheduledPublishingRepository.CompleteClaimAsync) already validated against the specific version's ownTemplateVersionId, not the item-level one, and was unaffected;ResolvedContentListQuery,GetBySlug's resolution path, andCmsify.Components'ContentEditSupport(inline child loading) all likewise key off a version's ownTemplateVersionId, not the item's.CreateVersionnow resolves the template version to validate against per the actual semantics of the request instead of the item-level field: when duplicating, it uses the SOURCE version's ownTemplateVersionId(the template version its copied field values actually belong to); otherwise, it uses the item's latest existing version'sTemplateVersionId(the template version the content is already sitting on), so a plainCreateVersionafter an upgrade validates the caller's fields correctly too, without ever silently jumping content onto an unrelated newer template version the caller never asked to upgrade to. Separately,UpgradeTemplateVersionnow also keepsContentItem.TemplateVersionIditself in sync - set to the target template version - whenever the version just upgraded is the item's latest version (byVersionNumber), so the informational item-level field read byList'stemplateVersionId/templateIdfilters and byToItemSummaryResponseAsync/ToItemDetailResponseAsync's "template name" lookup no longer drifts from reality; upgrading an older, non-latest draft deliberately leaves it untouched rather than regressing it behind a newer version that's already ahead.
[0.6.1] - 2026-09-16¶
Fixed¶
-
POST /api/v1/workspaces/{ws}/content/{id}/versions/{n}/publishintermittently 500'd with aDbUpdateExceptionwrapping Postgres 23505duplicate key value violates unique constraint "ix_content_versions_content_item_id"- seen in production for exactly the case where an older in-flight version (Draft/Approved) was published after a newer version had already taken the default-Published slot. That index is a partial unique index onContentItemIdfiltered to default-published rows (status = 'Published' AND effective_start_at/effective_end_at IS NULL), and Postgres cannot make a partial indexDEFERRABLE- it is checked per-statement, not at commit.ContentPublishingService.PublishAsyncmarked the prior default-published version Archived and the target version Published as two tracked changes flushed by a singleSaveChangesAsync, andContentController.Publishadditionally flipped the target'sStatusto Published up front viaContentLifecycleService.TransitionAsyncbefore callingPublishAsyncat all. Content version ids are UUIDv7 (time-ordered); when the version being published had a lower id than the currently-published one, EF Core's batchedUPDATEs could reach Postgres as "target → Published" before "prior → Archived", leaving two default-published rows for an instant and violating the index.PublishAsyncnow flushes the prior version's archival with its ownSaveChangesAsyncbefore the target version'sStatusis set to Published, guaranteeing the archival always lands first regardless of id ordering;ContentController.Publishno longer pre-sets the target'sStatusviaTransitionAsync(PublishAsyncalready sets every field that call would have) and now wraps the whole publish - archival flush, target status flush, and thecontent.version_publishedoutbox enqueue - in one explicit database transaction, so a later failure rolls the archival back too instead of stranding the prior version as Archived with no Published successor.ScheduledPublishingRepository.CompleteClaimAsync(the scheduled-publish worker's path, also callingPublishAsync) already ran inside its own explicit transaction and needed no changes beyond the fix inPublishAsyncitself. -
ContentEditPanelTests.RequireSlugBlocksSavingWithBlankSlugAndIssuesNoRequestkept failing intermittently on CI (including themainrun for 0.6.0) despite 0.5.4'scut.InvokeAsyncchange, because the real cause was not a render race on the click. The test waited for any<input>to render, butContentEditFormrenders its slug/locale/tags metadata inputs before the template's fields have loaded, soFind("input")could return the slug input: "My Title" was typed into the slug, the RequireSlug guard legitimately passed, and the expected slug error never appeared. The test (and six siblingContentEditPanelTestsusing the same wait/lookup) now wait for and target.cmsify-form-fields inputexplicitly, and the RequireSlug test awaits each dispatched event so any dispatch failure surfaces as its real exception instead of an unobserved task. No product code changed.
[0.6.0] - 2026-09-16¶
Changed¶
- Opening a content item in
ContentEditPanelwas slow, especially for templates with Inline child content:ContentController.ToVersionDetailResponseAsyncrecursively expanded every field'sChildContentItemIdone at a time (ResolvePublishedVersionAsync, one sequential DB query per child, up to 8 levels deep), and re-queried the same template's name/fields once per occurrence in the tree. Child resolution is now batched per recursion layer - one query resolves every distinct child id referenced anywhere at that depth (published, effective as ofasOf, not soft-deleted, most-specific-per-item), instead of one query per child - and template name/field lookups are memoized perTemplateVersionIdfor the lifetime of a single response build.GetVersion,CreateVersion,UpdateVersion, andGetBySlugalso gained anexpandChildrenquery parameter (defaulttrue, preserving the exact existing response shape for every current consumer) so a caller that never reads the nestedChildresponse - onlyChildContentItemId- can skip the expansion entirely.Cmsify.Components'ContentEditPanel/ContentEditSupport(which only ever readChildContentItemId, neverChild) now requestexpandChildren=falseon every version load/create/update they issue; the correspondingSyntaxCircus.Cmsify.Client.ContentClient.GetVersionAsync/CreateVersionAsync/UpdateVersionAsyncgained a matching optionalexpandChildrenparameter (defaulttrue) to support this. ComponentSchemaResolver.ResolveAsync's breadth-first component-schema resolution fetched one component at a time. It now resolves each BFS layer's distinct unresolved ids concurrently withTask.WhenAll, keeping the same cycle-safety (the result dictionary is still the visited set) and per-idCmsifyApiExceptionswallowing as before. It also gained an optionalseedparameter so an ancestor's already-resolved schemas can be reused instead of re-fetched by its descendants;ContentEditSupport.LoadInlineChildInstanceAsync/SaveInlineFieldAsyncnow thread the parent's own resolved schemas down as that seed for every Inline child of one load/save (each call still resolves into its own private dictionary, so this is safe across the concurrently-loaded siblings at any one level).ContentEditSupport.LoadInlineChildInstanceAsyncno longer eagerly mints a Draft version for every Inline child that lacks one when a parent is opened for editing - it now just reads whichever version (Draft if one exists, otherwise the serving/latest version) is already current, purely for display. A Draft is minted lazily, only when that child's own edits are actually saved (SaveInlineFieldAsync, by duplicating the displayed version), matching the existing ETag-priming/412-refresh handling for a newly-created child.LoadInlineChildInstanceAsyncalso now accepts the caller's already-fetchedTemplates.ListAsyncresult so every Inline child in one load reuses it instead of refetching it per child.- The TypeScript client's checked-in OpenAPI snapshot and generated
schema.tsnow include the new optionalexpandChildrenquery parameter on the content version and by-slug endpoints.
[0.5.5] - 2026-09-15¶
Fixed¶
CmsifyOpaqueBearerAuthenticationHandler.VerifyApiClientCandidatesAsync"touched"ApiClient.LastUsedAton every API-client-authenticated request (at most once perAuth:ApiClientTouchIntervalSeconds) by mutating a tracked entity and callingSaveChangesAsync, andApiClientuses PostgreSQLxmin-based optimistic concurrency. Two requests authenticating with the same API key inside the same touch window raced: the first save bumped the row'sxmin, the second's threw an unhandledDbUpdateConcurrencyException, surfacing as a 500internal-server-error- seen repeatedly in production for content-delivery endpoints under ordinary concurrent traffic. The touch now goes throughExecuteUpdateAsync, a raw conditional update that bypasses the change tracker and the concurrency token entirely, so concurrent touches settle as a race-free "last write wins" on this purely informational timestamp.ResolveUserSessionAsync's structurally identical session-touch logic (LastSeenAt/ExpiresAt/IpAddress) got the sameExecuteUpdateAsynctreatment for consistency, even thoughUserSessionhas no concurrency token today and could not throw the exception above.
[0.5.4] - 2026-09-14¶
Fixed¶
ContentEditPanel.SaveAsync's pre-flight Inline-child validation,InlineChildValidation's nested-field recursion, andContentEditSupport.SaveInlineFieldAsync's per-child-field loop all treatedCompositionMode == Inlinealone as "this field embeds a child ContentItem." A.ctpschema'sCompositionModeis a required property on every field, and many schemas (including production schemas that predate Inline child-content support) set it toInlineuniformly as a default rather than reserving it for genuine template-reference fields. Any such schema's ordinary required Text/Markdown/component field failed to save: pre-flight validation checked its (always-empty, since it's not a composition field)ChildInstancescount againstMinOccurrencesand rejected the save with e.g.'Title' requires at least 1 entry— even though the field's actual value was fully populated and correctly displayed. The request never reached the server. All three call sites now share a newContentEditSupport.IsInlineChildFieldhelper, matching theTemplateId/IsOpen/AllowedTypescomposition checkContentEditPanel.SaveAsync's main save loop already used correctly.ContentEditPanelTests.RequireSlugBlocksSavingWithBlankSlugAndIssuesNoRequestused a plain unguardedFind().Click()on the save button, which could race a pending render under CI's coverage-instrumentation overhead - the same symptom already fixed for two other tests in 0.4.10/0.5.2's changelogs. This one was missed and intermittently failed themainCI workflow (including blocking this release's ownv0.5.3tag, which published nothing and is abandoned). Wrapped the same interaction incut.InvokeAsync(...), matching the established pattern.
[0.5.3] - 2026-09-14¶
Tag pushed but dotnet test failed in CI before packaging (a flaky test, fixed in 0.5.4 above) - no packages were ever published under this version. Left here for the historical record; treat 0.5.4 as the direct successor to 0.5.2.
[0.5.2] - 2026-09-14¶
Fixed¶
InlineChildContentEditorTests' removal test asserted onEventCallback-captured state immediately after clicking the remove button, without waiting for the render to settle first - unlike every other assertion of this shape in the suite. Under coverage instrumentation's added scheduling overhead this raced and intermittently failed the.NET testsworkflow onmain. It now waits for the callback to fire before asserting, matching the pattern used elsewhere.- Two
ContentEditPanelTestsinline-child-removal tests plainFind().Click()'d the remove/save buttons, which can race a pending render and throw bUnit'sUnknownEventHandlerIdException- exposed by the bunit 2.9.0 → 2.11.3 bump below. Wrapped the same interaction incut.InvokeAsync(...), matching a sibling test in the same file that already guards against this. - A Dependabot NuGet group bump left
Microsoft.Extensions.Hosting.Abstractionscentrally pinned below the versionMicrosoft.AspNetCore.Mvc.Testingnow transitively requires, leaving everypackages.lock.jsoninconsistent with the project graph and locked restore failing. Bumped the pin to match and regenerated the lock files.
Changed¶
- Updated dependencies via Dependabot: GitHub Actions (
actions/checkout,actions/setup-node,actions/setup-python,actions/deploy-pages,actions/download-artifact), NuGet packages (bunit,Microsoft.AspNetCore.Authentication.JwtBearer,Microsoft.AspNetCore.Http.Abstractions,Microsoft.AspNetCore.Mvc.Testing,Microsoft.Testing.Extensions.CodeCoverage, severalMicrosoft.Extensions.*packages,Microsoft.NET.Test.Sdk,StackExchange.Redis,xunit.v3), and@types/nodeinsdk/typescript.
[0.5.1] - 2026-09-14¶
Fixed¶
ComponentFieldEditor(the structured editor forcomponentReffields added in 0.5.0) rendered each nested field's input with no<label>at all, unlikeContentEditForm's top-level fields - a user editing e.g. a "titled-copy" component's eyebrow/title/body saw three unlabeled boxes. The field'sLabel/HelpTextalready survivedComponentFieldAdapter's conversion correctly; only the markup was missing. Nested fields now render the samecmsify-form-label/cmsify-form-helpmarkup as top-level fields.
[0.5.0] - 2026-09-14¶
Added¶
- Component-typed fields (
TemplateField.ComponentId) now render as first-class, recursive structured editors inCmsify.Componentsinstead of a raw JSON textarea — nested component-in-component fields, all primitive field types (including Media/File, referenced as a GUID within the component's snapshot JSON), and pick-list bindings all render and save correctly, with add/remove of repeated instances respectingMinOccurrences/MaxOccurrences. CompositionMode.Inlinetemplate-reference fields now render a first-class, recursive child-content editor inCmsify.Componentsinstead of a "not available yet" placeholder — a user can create, edit, and remove real childContentItems directly embedded in the parent's form, at any nesting depth. Since there is no server-side cascade-delete for Inline children and no server-side cycle protection for polymorphic (IsOpen) composition fields, the editor enforces a client-side depth/cycle guard (8 levels) and performs save writes children-before-parents, aborting on any child failure and deleting removed children only after the parent's own save succeeds.- Documented when to use a component field versus a template-as-child-field (
Referencevs.Inlinecomposition) indocs/content-modeling.md.
Changed¶
- Breaking:
ContentFieldEditorValue.ComponentValuesis nowIReadOnlyList<ComponentInstanceValue>(wasIReadOnlyList<string>of raw JSON).ContentEditForm's andFieldEditor'sOnMediaPickRequested/OnFilePickRequestedcallbacks are nowEventCallback<ContentFieldEditorValue>(wereEventCallback<TemplateFieldResponse>), andContentEditForm.FieldValuesis nowIDictionary<Guid, ContentFieldEditorValue>(wasIReadOnlyDictionary<...>).ComponentFieldEditor's entire parameter surface changed to support structured editing. Consumers who renderContentEditForm/FieldEditor/ComponentFieldEditordirectly (rather than through the SDK-backedContentEditPanel) will need to update call sites;ContentEditPanelitself absorbs all of these changes transparently.
[0.4.10] - 2026-09-13¶
Fixed¶
ContentEditPanel.LoadContentAsync/LoadPickListsAsync/LoadReferenceOptionsAsyncfetched each media/file asset, pick-list revision, and referenced-template option list one at a time in a sequential loop, turning a single content item's load into 5-10+ back-to-back round trips against Cmsify's API - painfully slow for any template with more than a couple of such fields. All three now gather the independent lookups first and run them concurrently viaTask.WhenAll, preserving each item's own per-item error handling (a failed media/file lookup still falls back to its raw ID; a failed pick-list revision is still silently skipped) exactly as before - only the timing changed, not the behavior.ContentListPanel.OnParametersSetAsync's initial template-list and content-list fetches are similarly now run concurrently instead of sequentially.
[0.4.9] - 2026-09-13¶
Added¶
ContentEditPanelgained an optionalRequireSlugparameter: when set,SaveAsyncrejects a blank slug immediately (setserror, issues no API request) instead of silently creating/updating content with a null slug that a consumer's own downstream logic can't use.ContentEditPanel/ContentEditFormalso gained aBusysurface —ContentEditPanel.BusyChanged(EventCallback<bool>) fires around the save API call, andContentEditForm's own Save button now disables and reads "Saving…" while busy — so consumers no longer need to guess whether a save click actually did anything.
[0.4.8] - 2026-09-13¶
Fixed¶
ContentEditPanel.OnParametersSetAsynchad no exception handling aroundLoadContentAsync/LoadTemplateVersionAsync, so any failure loading the initial content or template (an expired/invalid API token, a deleted template, a network error, etc.) propagated straight out of component initialization and crashed the whole hosting circuit before the form ever rendered — the same class of bug 0.4.7 fixed forSaveAsync, just on the load path instead. It now setserrorand invokesOnError(if bound) the same waySaveAsyncdoes, so the form still renders (empty) with the failure surfaced instead of taking the circuit down.
[0.4.7] - 2026-09-13¶
Added¶
ContentEditPanelgained an optionalOnErrorparameter (EventCallback<Exception>), invoked wheneverSaveAsynccatches an exception (from the save itself or from aSaved/Created/ItemChangedconsumer callback), alongside the existing inlineerrormessage it already renders. Lets consumers hook additional behavior — logging, telemetry, a toast — off of save failures without scraping the rendered error text. AOnErrorhandler that itself throws cannot escapeSaveAsync; it's swallowed rather than risking the circuit-crash bug below.
Fixed¶
ContentEditPanel.SaveAsynconly caughtCmsifyApiExceptionaround invoking itsSaved/Createdcallbacks, so any other exception thrown by a consumer's callback (e.g. a host app's own post-save side effect) propagated straight out of the Blazor event handler and terminated the whole hosting circuit, with nothing shown to the user beyond a silent, frozen form. Acatch (Exception ex)now setserrorfor any non-CmsifyApiExceptionfailure from that invocation too, so a misbehaving consumer callback surfaces a message instead of taking down the host's circuit.
[0.4.6] - 2026-09-13¶
Fixed¶
- Release pipeline:
Cmsify.ComponentsandCmsify.Components.Themeshipped zero static web assets in every previously-published version (nowwwroot/scoped-CSS content at all, only the compiled DLL), despite both projects genuinely having real content (Cmsify.Components' 14 scoped.razor.cssfiles,Cmsify.Components.Theme'swwwroot/cmsify-theme.css) — a consuming app referencing_content/SyntaxCircus.Cmsify.Components.Theme/cmsify-theme.cssor expectingCmsify.Components' scoped styles to bundle into its own isolation CSS got a 404/empty response instead.IsPackableis gated off by default (Directory.Build.props) unlessCmsifyReleaseBuild=true; the release workflow's one realdotnet buildstep never set that, so it ran withIsPackable=false, and the laterdotnet pack --no-build -p:CmsifyReleaseBuild=truecouldn't retroactively compute the Razor SDK's static-web-asset-to-package items — those are only wired up whenBuilditself runs withIsPackable=true, and--no-buildskips re-running it.CmsifyReleaseBuild=trueis now also passed to thedotnet buildstep.
[0.4.5] - 2026-09-12¶
Fixed¶
- EF Core logged a
MultipleCollectionIncludeWarning(visible in OTel) for any query that loaded more than one collection navigation without an explicitQuerySplittingBehavior, e.g.ContentController.CreateVersion/UpgradeTemplateVersionloading a template version's fields together with each field's allowed types, and the equivalent patterns inTemplateVersionRepository,TemplatesController,PackagesController, andComponentsController.QuerySplittingBehavior.SplitQueryis now the default for the Npgsql provider, which both silences the warning and avoids the cartesian-product row explosionSingleQueryproduces when joining multiple sibling collections.
[0.4.4] - 2026-09-12¶
Fixed¶
POST .../content/{id}/versions/{versionNumber}/upgrade-template-versionwiped every field value on upgrade, not just genuinely removed ones - it matched a version's existing values against the target template version's fields by field Id, but a package re-import always mints brand-newTemplateFieldrows for every field on every template version, even one whose key never changed. In practice this meant upgrading any content onto a newer template version always emptied every field, then immediately failed its own post-upgrade validation the moment any field was required. Values are now remapped onto the target field with the same key; only a value whose key genuinely no longer exists in the target is dropped.- Suppressed chatty
Microsoft/SystemSerilog categories soMicrosoft.EntityFrameworkCore.Database.Commandstops flooding the OTel collector at Information level.
[0.4.3] - 2026-09-11¶
Fixed¶
- Package import: importing a package that both introduced a brand-new component and required an explicit "replace" resolution for an already-installed component (e.g. adding a field to an existing component) failed with an opaque 500 (
DbUpdateConcurrencyException) instead of succeeding.PackagesController.CreateComponentVersiononly attached the replacementComponentVersionvia its parent'sVersionsnavigation collection; sinceEntity.Idis assigned client-side at construction (Guid.CreateVersion7()), EF Core's change detection saw a non-default key reached only through an already-tracked (unchanged/modified) parent and inferred the row already existed, issuing anUPDATEwhose optimistic-concurrency check then never matched instead of anINSERT. The equivalent PickList "replace" path (AddRevision) already explicitly added its new revision to theDbContext;CreateComponentVersionnow does the same.
[0.4.2] - 2026-09-10¶
Fixed¶
- Release pipeline:
quay.io/skopeo/stable's tag digest was being rotated and garbage-collected by quay.io on a roughly 1-3 day cadence, breaking the pinned Skopeo helper used byartifact-smoke,candidate-accessibility, andupgrade-rollback(this exact failure recurred six times). The helper is now pinned againstghcr.io/syntax-circus/skopeo, a mirror this org controls. upgrade-rollback's assertions (eng/upgrade-tests/assertions.mjs) were never updated for 0.4.0's version-centric Content API breaking change, so the job carriedcontinue-on-error: trueto mask real, permanent failures - which itself broke the release-contract governance test suite on every PR. The assertions are fixed against the current API contract andcontinue-on-erroris removed from bothpublish-cmsify.ymlandupgrade-rollback.yml; the release gate fails closed again.- v0.4.1's release could not complete due to the Skopeo failure above; this release carries no functional changes beyond it and the previous fix.
[0.4.1] - 2026-09-09¶
Added¶
- A "View" action in the Admin content list and a new read-only content viewer (
/workspaces/{workspaceId}/content/{id}/view, optionally/view/{versionNumber}) for inspecting a content item's currently-published (or any specific) version without creating a Draft. Previously, opening "Edit" on a Published or Archived item always minted a new Draft viaCreateVersionAsync, even when the user only wanted to look at the content — there was no way to see it otherwise short of the crude raw-table "Inspect" modal on the Versions page. The viewer reuses the existingSyntaxCircus.Cmsify.Componentsfield editors via a new non-interactiveReadOnlymode (added toFieldEditor,FieldEditorRenderContext,ContentEditForm,ContentEditPanel,ContentListView/ContentListPanel, and every field-type editor) instead of a second renderer, so formatting, resolved pick-list labels, and asset names render exactly as they do when editing. Reader-role users clicking "Edit" are now redirected into this read-only view instead of landing on a form they can't successfully save.
[0.4.0] - 2026-09-07¶
Added¶
- Content duplication in the Admin content editor (a new "Duplicate" button) — clones an existing item's fields into a new draft under a fresh slug/translation group, keeping locale and tags. Nothing equivalent existed anywhere before (domain, API, either SDK, or Admin UI).
Changed¶
- Breaking:
ContentVersionis now the sole carrier of content fields and workflow lifecycle (Draft → Review → Approved → Published → Archived);ContentItemis now a lightweight slug/template/locale identity header. This enables independently-editable, independently-scheduled date-bounded versions of the same content item. The Content API's item-level workflow routes (/content/{id}/submit,/approve,/reject,/publish,/archive,/restore,/rollback,/upgrade-version) are replaced by version-level equivalents (/content/{id}/versions/{versionNumber}/submit, etc.);GetBySlugnow resolves to aContentVersionDetailResponseinstead ofContentItemDetailResponse. The TypeScript SDK'scontent.bySlugreturn type changes accordingly. This is a breaking change to the Content API and its contracts — accepted as acceptable pre-release, since there are no external consumers of the API or the TypeScript SDK yet. - Breaking (database): the accompanying migration (
UnifyContentVersionLifecycle) is not a pure schema change — it carries a hand-written data backfill. It moves everyContentItem's status, publish schedule and field values onto aContentVersion(materialising a new version row for any item that did not already have a matching one), renamescontent_versions.retired_attoarchived_at, rewrites theRetiredstatus toArchived, seedscreated_at/updated_aton pre-existingcontent_versionsrows, and then drops thecontent_field_valuestable along with the movedcontent_itemscolumns. Operators upgrading an existing deployment should back up before applying it:Down()restores the dropped structure but does not reverse the data movement. - Breaking (webhooks): content webhook event types now distinguish item lifecycle from version lifecycle. Added:
content.version_created,content.version_updated,content.version_deleted,content.version_status_changed,content.version_published,content.version_template_upgraded. Removed (nothing emits them any more, and subscription requests naming them are now rejected):content.published,content.status_changed,content.archived— existing subscriptions to these must be repointed, most oftencontent.published→content.version_published.content.created/content.updated/content.deletedare unchanged and still describe item-level lifecycle. Both publish paths (the API's publish action and the scheduled-publish background worker) now emit the samecontent.version_publishedevent; previously the scheduled worker emittedcontent.published. - Breaking (audit log): audit entries for content status changes now record an
EntityTypeofContentVersioninstead ofContentItem, since the status being changed belongs to the version. Audit queries filtering onentityType=ContentItemto find status changes need updating.
Fixed¶
Cmsify.Admin(the Blazor admin UI) and the separatesdk/dotnet.NET client SDK — both left non-functional by the version-centric Content API change above — are restored.Cmsify.Admin's Content pages (list, editor, version history, publish dialog, workspace dashboard) andsdk/dotnet'sContentClientnow target the version-scoped API. Note: the Admin publish dialog no longer offers the bounded-window ("publish as bounded override") inputs it previously had — that capability didn't survive the restoration and belongs to a future dated-version-management UI, not this pass; the dialog otherwise still supports scheduling a publish date and, for Admins, overriding the workflow gate. The Content list also no longer shows a definite status/workflow actions for an item with no currently-serving version (e.g. never-published, or Archived) — it shows "—" and a link into the editor instead, since the list's summary data can't distinguish those states; the editor page still resolves and displays the item's real status correctly.- The Admin content editor no longer mints a new, orphaned Draft version every time it's reopened for a Published or Archived item. It previously re-derived the version to edit from the item's currently-serving version on every page load, ignoring any Draft/Review/Approved version that already existed — so repeat visits (and even Rollback, immediately followed by landing back on the editor) kept spawning throwaway Drafts instead of resuming the one already there. It now reuses the latest already-editable version whenever one exists.
- The Admin content editor no longer shows a false-positive "Content changed while saving" warning on a normal, single-editor save. Saving issues two sequential writes (the version, then the item's slug/locale/tags); the version write bumps the parent item's
UpdatedAtas a side effect, which invalidated the item write's optimistic-concurrency ETag captured at page load — so the warning fired deterministically on every save, not just real conflicts. The item's ETag is now refreshed between the two writes. - Creating a template field with
isOpen: trueand anallowedTypesentry that also specifies aprimitiveTypeis now rejected (422) at creation time. Previously this was silently accepted, then produced a confusing "expects a child content value" error the first time any content was submitted against the field. - Dark-mode contrast across the Admin app: shared
Cmsify.Componentsform labels (viaCmsify.Components.Theme) rendered near-black on Admin's dark background because the theme's--cmsify-*variables were static light-mode values, never wired to Bootstrap's[data-bs-theme="dark"]convention — they now track the active theme's--bs-*variables instead. Bootstrap's outline button variants (.btn-outline-secondaryand, once the Content list started using more of them,-primary/-info/-success/-warning/-danger) bake their colors to fixed light-mode hex values at compile time with no native dark-mode handling; dark-mode overrides now cover all of them, including disabled-button state (Bootstrap's extra opacity on:disabledwas making already-muted colors unreadable). - The Admin sidebar's build-version footer overflowed its container showing the full informational version string; it now shows a short version (without the
+shasuffix). The About page's Admin/API version-match indicator is unaffected and keeps showing the full version. - The Content list's Edit button now uses real Bootstrap
btn btn-smclasses (via a newEditButtonClassparameter on the sharedContentListView/ContentListPanelcomponents) instead of hand-approximated CSS, so it's pixel-identical to its row-action siblings instead of visibly oversized. Row-action buttons are recolored from a uniform, flatbtn-outline-secondaryto distinct, semantically-appropriate variants (Edit=primary, Submit=info, Approve=success, Reject=warning, Publish=success filled, Archive=danger, Restore=secondary), and their spacing no longer silently depends on incidental HTML whitespace between buttons that Razor could trim away.
[0.3.2] - 2026-09-06¶
Added¶
- New publishable package
SyntaxCircus.Cmsify.Components: headless, restylable Blazor Server components for editing and managing Cmsify content, for embedding content editing directly in a consuming site instead of sending users to Cmsify Admin. Includes a field editor for every template field type, aFieldEditordispatcher with a per-field-type override hook, composedContentEditForm/ContentListViewcomponents, SDK-backedClient.ContentEditPanel/Client.ContentListPaneldrop-in panels, and a shared media/reference picker. Structural styling only; every visual property is a--cmsify-*CSS custom property. - New publishable package
SyntaxCircus.Cmsify.Components.Theme: optional default styling forSyntaxCircus.Cmsify.Components, matching Admin's current look. Purely static CSS; omit it to theme the components yourself.
Changed¶
- The Admin app's Content Editor and Content List pages now run on the new
SyntaxCircus.Cmsify.Componentspackage instead of Admin's previous hand-rolled markup, and its bespoke media picker has been replaced by the shared component.
Fixed¶
- The Admin sidebar's build-version display (added in 0.3.1) sometimes rendered as literal text (
v@AdminBuildInfo.Version) instead of the actual version, due to a Razor markup-parsing quirk. It now always shows the resolved version.
[0.3.1] - 2026-09-05¶
Added¶
- The Admin app now shows its running build version in the sidebar and on a new
/aboutpage, alongside the API's reported version (read from/health/ready) with a match/mismatch indicator — useful for confirming a deploy landed both images in sync. - Admins can now publish content directly from Draft or Review, skipping Submit/Approve, via a confirmation dialog in the Admin UI (
PublishContentRequest.OverrideWorkflow, requiring both the Admin role and explicit opt-in — existing callers see no behavior change unless they opt in). - Reject (Review → Draft) and Restore (Archived → Draft) buttons on the Admin content list — both existed in the API already but had no UI entry point.
- The Content Editor's Lifecycle card now shows the same workflow action buttons (Submit/Approve/Reject/Publish/Archive/Restore) as the content list, so reviewing content no longer requires navigating back to the list.
Changed¶
- The .NET SDK's
HealthClient.LiveAsync/ReadyAsync(client.Health) now return a typedHealthCheckResponse(withStatusandMetadata.Version/Metadata.GeneratedAt) instead of an untyped payload.
Fixed¶
- Release promotion now also publishes the
:latestDocker Hub tag alongside the version-numbered tag for stable (non-prerelease) releases. Previously only the versioned tag was pushed, leaving:lateststuck on an old release. - Content workflow buttons (Submit/Approve/Archive) in the Admin UI previously failed silently when clicked on an item in the wrong status. They're now disabled with a tooltip explaining why when not applicable, and any remaining server-side rejection shows an error toast instead of doing nothing.
[0.3.0] - 2026-09-05¶
Added¶
- Client-side
${{name}}template rendering (CmsifyTemplateRenderer.Renderin the .NET client,renderCmsifyTemplatein the TypeScript client) for substituting caller-supplied variables into Text/Markdown field values read from Cmsify content. Purely opt-in and client-side; the server has no concept of variables. See "Rendering field templates" indocs/integrating.md.
Fixed¶
- An expired Admin session no longer redirects to a raw HTTP 400 error page. Signing back in after expiry now always reaches the login form, and any remaining antiforgery-token mismatch on the login/logout endpoints redirects to the login page with a friendly "session expired" message instead.
[0.2.4] - 2026-09-04¶
Fixed¶
- Release restores now pin the Admin and Admin integration projects to the repository-signed
SyntaxCircus.Http.Resiliencepackage hash, matching the public .NET client dependency graph.
[0.2.3] - 2026-09-04¶
Added¶
- Optional OpenTelemetry/SigNoz and Sentry/GlitchTip telemetry is available to the API and Admin hosts through the reusable
SyntaxCircus.Observabilitypackage, without changing the existing configuration sections.
Fixed¶
- Creating a workspace in the Admin UI now selects and displays it immediately instead of leaving the workspace state empty until a browser refresh.
[0.2.2] - 2026-09-04¶
Fixed¶
AddCmsifyClientis now covered by a regression test proving itsAddTypedClient-based registration (introduced in 0.2.0) avoids the constructor-ambiguity crash (InvalidOperationException: Multiple constructors accepting all given argument types...) that the naiveservices.AddHttpClient<CmsifyClient>()pattern still hits. This fix shipped silently in 0.2.0 as part of the HTTP resilience consolidation; this entry makes it discoverable for anyone who hit the crash on 0.1.x.
[0.2.1] - 2026-09-01¶
Changed¶
- Releases are certified from a reviewed immutable SemVer tag. Branch and pull-request builds validate only and never publish artifacts or create tags.
- The TypeScript SDK uses the owned npm identity
@syntaxcircus/cmsify-client; trusted publishing leaves token-style registry configuration unset so npm can exchange the GitHub Actions OIDC token.
Release note¶
v0.2.0was not completed as a GitHub Release. Its NuGet submissions and OCI images were accepted before npm rejected the unowned@cmsify/clientscope. That tag remains immutable historical evidence; the next complete same-source release isv0.2.1.
[0.2.0] - 2026-08-31¶
Added¶
- First-party .NET and TypeScript SDK packages, Admin accessibility certification, production-like release smoke tests, and deterministic upgrade/rollback rehearsal.
- OIDC administration support, durable media reconciliation, package import/export, and release provenance/SBOM attestations.
Changed¶
- Public SDK packages (
SyntaxCircus.Cmsify.Contracts, both .NET clients, and@syntaxcircus/cmsify-client) are MIT-licensed; the server repository and OCI images remain AGPL-3.0-or-later. - Workspace responses now include the actor-specific
canWritecapability for permission-aware clients. - The Admin app now generates slugs from a new workspace, template, picklist, or component name until the slug is manually edited.
- User-management forms show API validation details inline.
- Workspace management and selection now honor user role and per-workspace grants. The workspace picker is selectable only when multiple workspaces are available.
- Admin navigation now shows only settings available to the current role; webhook management remains an Editor-level, workspace-scoped feature.
- Admin static CSS, scripts, and branding assets use Blazor asset fingerprinting so deployments receive changed assets without stale browser caches.
- API JSON uses Cmsify's shared camel-case, string-enum wire format consistently.
Fixed¶
- Unauthorized API requests now return normal
401or403responses instead of requiring an unconfigured authentication scheme.
[0.1.3] - 2026-08-21¶
Fixed¶
- Corrected workspace permissions and administration authorization behavior.
[0.1.0] - 2026-08-20¶
Added¶
- Initial Cmsify release with a versioned HTTP API, PostgreSQL persistence, and a Blazor administration UI.
- Versioned templates, inline components, choice sets, content lifecycle, media, API clients, workspaces, audit history, webhooks, and scheduled publishing.
- First-party TypeScript and .NET clients for server-side integrations.