Skip to main content

Content releases

A release is a named bundle of publishes and unpublishes that goes live as one coordinated change (#500). A campaign that touches a landing page, three posts, a menu fragment and a retired announcement ships as a single unit at a single moment — or, if any one piece can't ship, none of it does.

Before this, coordination meant lining up N identical scheduled_at timestamps and hoping. This is the same idea Contentful calls Launch and Sanity calls Releases.

Not to be confused with Releasing, which is about cutting a version of KilnCMS itself.

Using it

Releases in the editor sidebar (/editor/releases) lists releases in three groups: Planned (open, scheduled, publishing, failed), Published, and Closed (rolled back, archived).

  1. Create a release from the form at the top of the list — a name and optional notes.

  2. Add content to it, from either end:

    • In the content list (/editor), tick the records you want and use Add to release — the bulk path, for assembling a campaign in one pass.
    • In the content editor's Settings tab, the Release panel queues the record you are editing, shows which release it is already in, and takes it back out again — the path for "this one piece belongs in Friday's launch", without leaving the editor.

    Either way you choose the release and whether it should Publish or Unpublish that record when the release goes live. You can flip that choice later from the release page — click the value in the Change column. It is an edit in place rather than a remove-and-re-add, which matters: removing frees the content's reservation, so between the two steps another release can legitimately claim the record and the re-add fails.

  3. Review it. The release page lists everything in the bundle with its title resolved, and badges any item that couldn't ship as it stands (e.g. "cannot be published from archived") or that would be a no-op ("already in that state — will be skipped"). Those verdicts are also totalled next to the ship controls, because one blocked item means the whole release fails — on a large release, a single red badge in a long list is not a warning anybody reliably sees.

  4. Preview it. Create preview link mints a short-lived, shareable URL showing every document as the release would publish it, plus everything the release takes down. No editor account needed to open it — useful for sign-off with people outside the CMS.

  5. Ship it. Either set a Go live at datetime (a minute-resolution cron fires it, same cadence as per-item scheduling) or hit Publish now.

    Publish now is withheld while any item is blocked, or while the release is empty — the go-live could only abort, and the page already knows it. Scheduling only warns. The difference is deliberate: a readiness verdict is about right now, and the entire point of a go-live date is that the blocking record gets fixed before it arrives. Refusing to schedule would forbid the normal way of working; scheduling over a known blocker in silence is how you get a 09:00 launch that was never going to ship.

  6. Undo it. A published release offers Roll back: every item it changed returns to the state it was in before.

Who can do what

Composing a release is editor work; shipping one is admin work.

ActionEditorAdmin
Create and rename a release✅✅
Add / remove content✅✅
Reopen a failed release✅✅
Archive or delete a release that never shipped✅✅
Archive or delete a scheduled or published release❌✅
Schedule, Publish now, Roll back❌✅

This mirrors Kiln's existing rule that publishing is an admin approval step: a release must not become a way for an editor to publish content they otherwise couldn't. The go-live worker necessarily runs unauthorized (it publishes across types on behalf of the bundle), so the gate is at the release, and the admin who scheduled or started it is recorded and used as the acting user for every item — version history and the audit chain name a person, not "system".

Closing out follows the same logic. Archiving is one-way, so archiving a published release permanently ends its group rollback, and archiving or deleting a scheduled one silently cancels a launch somebody else planned. Both are admin calls; an editor can still close out a release nobody has committed to.

Adding content to a release also respects the granular-RBAC type scope (#332): an editor restricted to post cannot put a page in a release. That matters more here than elsewhere, because the preview link below renders every item's unpublished body to whoever holds it — so a release can only ever expose what the editor who filled it was already allowed to see.

How large a release can get

A release is capped at 500 items by default. The console shows how many slots are used, warns at 80%, and the add is refused once the cap is reached.

The cap exists because the single go-live transaction — the thing that makes the whole feature's guarantee true — holds row locks on every item for its duration, and the console recomputes readiness across every item on each render. Both scale with item count. The default is deliberately generous (a site-wide migration release is a real use); it is there to stop unbounded accidental growth from a bulk "select all", not to impose an editorial style.

How long a release may run

transaction_timeout_ms (two minutes by default) is a wall-clock budget, checked between items. A go-live that runs out of it rolls back like any other failure — nothing ships, and the release lands in Failed naming the item it stopped at, with a reason telling you to split the release or raise the budget.

Between items is the only safe place to stop. One item is a publish plus its version, audit-chain and webhook writes; interrupting it halfway would produce exactly the inconsistency the transaction exists to prevent. So the budget bounds how long the release may keep starting work, and the true worst case is one item's overrun past it.

Worth stating plainly, because the name invites the wrong assumption: this bound is Kiln's, not Postgres's. The :timeout option on Repo.transaction/2 reaches only the BEGIN, COMMIT and ROLLBACK statements — DBConnection invokes the callback as a bare fun.(conn) with no timer, and queries inside carry the repo's own default rather than inheriting it. Relying on that option alone, as this page previously described, would have let a release hold row locks on every one of its items indefinitely.

Both bounds are configurable:

config :kiln_cms, KilnCMS.CMS.Releases,
  max_items: 500,
  transaction_timeout_ms: 120_000

It is a guardrail rather than an invariant: the check is a count, so two simultaneous adds at the cap can overshoot by one. Nothing downstream depends on the exact number.

One record, one open release

A given piece of content can sit in at most one release that hasn't shipped. Adding it to a second one is refused. This is enforced by a partial unique index in Postgres, not by an application check, because two editors adding the same page to two different releases at the same moment is precisely the race a check-then-insert loses.

Removing the item, or archiving the release, frees the record again.

What "atomic" means here

The interesting question is what happens when item 7 of 10 fails.

Publishing N items through the normal per-item actions means N state transitions, N artifact fires and N webhook dispatches. That sounds like something no transaction could cover — but in Kiln every side effect of the publish path is a database write:

Publish-path effectWhat it actually does
NotifyWebhooksinserts a WebhookDelivery row + an Oban job
FireArtifactsinserts an Oban job
Automation dispatchinserts an Oban job
Governance.Chaininserts anchor rows

The actual HTTP POSTs and artifact renders are performed later, by Oban workers — and Oban shares the same repo. Nothing has left the database at the moment the release is running.

So KilnCMS.CMS.Releases runs the whole bundle inside one Repo.transaction. A failure on any item rolls back the earlier items and the webhook deliveries, artifact fires and automation jobs they queued. Subscribers never see a partial campaign; the site is untouched; the release lands in Failed with the offending item and the reason recorded on it.

The honest cost: one long transaction holding row locks on every item for its duration — bounded by transaction_timeout_ms (two minutes by default) and, at the other end, by the item cap above.

Skipped, not failed

If somebody publishes one of the pages by hand before the release fires, that item's desired end state already holds. Failing the entire launch over a no-op would be pedantic, so such items are marked Skipped — recorded, visible, and deliberately untouched by a later rollback, because the release didn't put them there.

A genuinely impossible transition — an archived record, a trashed one, a content type that no longer exists — is a real failure and aborts the release.

Retrying a failed release

Reopen for editing returns a failed release to open with its items intact and still pending, so you can fix the offending record and publish again. The go-live job does not retry itself: a release that aborted needs an editorial decision, not another attempt against the same broken item. Scheduling and Publish now are deliberately not offered on a failed release — reopening is the first step, and those transitions don't exist from failed.

A stuck claim

publishing and rolling_back mean "a worker owns this release right now". If that worker dies — a node restart, a job discarded — nobody is left to say otherwise, and the release would sit there forever with its items still reserving their content against every other release.

Release a stuck claim (admin only) is the way out: it returns a stuck go-live to failed and a stuck rollback to published, after which the normal reopen-and-retry path works. Only use it when you know no publish is actually running — that is the one thing the system can't determine for you, which is why it is a button and not automatic.

Preview as of a release

/preview/release/:token renders the site as the release would leave it: each publishing document through the same block components and public layout the live site uses, and a list of everything coming down.

Unlike point-in-time delivery — which has to replay :changes_only version history because the state it wants no longer exists anywhere — a release's future state is simply the live draft row. In Kiln a document's editable record is its next published state, with the fired artifact as the frozen public copy alongside it. So the overlay is a read, not a replay.

Preview links are signed with Phoenix.Token, stateless, and valid for one hour — wider than the 15-minute single-record draft preview, because a release review is a sign-off pass over several documents rather than a glance at one. A link opened on a different site's host is refused rather than rendered under the wrong tenant's branding.

Rollback

Rolling back walks the applied items in reverse and restores what each one replaced, using the prior_state and prior_version_id captured before the item's transition ran — the only moment those are knowable.

  • An item the release published goes back down, returning to draft, or to in_review if that's where it was (a plain unpublish always lands in draft, which would quietly lose a review in flight).
  • An item the release unpublished goes back up. If the record was edited while it was dark, its captured version is restored first, so rollback brings back the body that was actually live rather than whatever it drifted into. An untouched record is simply republished — no needless extra version.

An item whose content has since been deleted outright is recorded as undone and skipped. Failing on it would wedge the group: the release would return to published, the item would stay applied, and every retry would hit the same missing record — so one purged page would strand every other item of the release live, with no way to take the group down.

Otherwise rollback runs in one transaction with the same all-or-nothing guarantee: if any item can't be restored, the release stays Published and nothing moves.

Calendar and events

Scheduled, shipped and failed releases appear on the editorial calendar (/editor/calendar) alongside per-item schedules, with their own chip colours.

A failed release keeps the scheduled_at it was meant to fire at, so its chip stays on the day the launch was planned for rather than the day somebody noticed. It is its own lane (Release failed, in the error colour) and is not draggable — there is no schedule transition out of failed, so reopening the release is the first step and rescheduling comes after.

Three events dispatch through the standard webhook and automation funnel:

  • release.published
  • release.rolled_back
  • release.failed

All three carry the release's id, name and item list, and are subscribable on any webhook endpoint exactly like page.published. Because they go through the same funnel, an automation rule scoped to content type release with trigger published fires on them too. A rule for any content type does not: a release is not a document, so "any content type, on published" means the documents a release publishes (each fires its own <type>.published), not the release itself.

release.published and release.rolled_back are dispatched inside their transaction, so a release that aborts never emits one.

release.failed is necessarily the other way round — the transaction it would report on has already rolled back, so the dispatch happens after it, on its own. It additionally carries mode ("publish" or "rollback"), failure_reason and failed_item_id.

Subscribe to it. A release that aborts changes nothing on the site, which means nothing else about the system tells anybody it was supposed to: an unattended 09:00 launch that fails leaves the site exactly as it was, and without this event the first sign is somebody noticing the campaign never went out.

Under the hood

ModuleRole
KilnCMS.CMS.ContentReleasethe release: state machine, schedule, failure record
KilnCMS.CMS.ReleaseItemone pending change; the partial unique index lives here
KilnCMS.CMS.Releasesgo-live and rollback — the transaction
KilnCMS.CMS.Workers.ReleaseWorkerruns one go-live or rollback off-request
KilnCMS.CMS.ReleasePreviewthe overlay and its signed share token

States: open ⇄ scheduled → publishing → published | failed, with failed → open (reopen), published → rolling_back → rolled_back, publishing | rolling_back → failed | published (abandon), and archived as the close-out from anywhere that isn't mid-flight.

publishing and rolling_back are claim states rather than cosmetics: both the minute cron and the Publish-now button transition into them before any work starts. The claim is a compare-and-swap, not a compare-and-hope — the guard is in the UPDATE's own WHERE clause, so a console page held open since 08:59 (still showing scheduled after the 09:00 cron claimed the release) gets a stale -record error rather than shipping the bundle a second time. The worker's job is enqueued inside the claim's transaction, so a claim can never commit without one.