The open-source marketing CMS
Menu

Releases

A release (a content release; for Spillway's own software releases see dev/releases.md) is a named set of draft changes: many entities, the navigation menus and the site settings edited together, previewed as a whole on a private URL, compared with the live site and merged (published) or discarded in one action, now or on a schedule. Use one for a campaign launch, a redesign, a product launch or any change that only makes sense as a set. The idea borrows from Drupal's Workspaces and Pantheon's workstreams.

Releases were called workstreams until they were renamed to keep them apart from Pantheon's feature of that name. The old names still work: /api/workstreams... answers like /api/releases..., the X-Spillway-Workstream header and ?workstream= like X-Spillway-Release and ?release=, /admin/workstreams redirects to /admin/releases, and spillway import --no-workstream is --no-release. The types (Workstream, WorkstreamDiff, ...), the error codes (workstream_scope, workstream_read_only, workstream_conflicts), the audit actions (workstream.*), the notification event workstream.merged and the database tables keep the old name.

The contract lives in packages/shared/src/index.ts (section "Releases"); the storage is packages/core/src/db/migrations/0020_workstreams.ts; the code is under packages/core/src/workstreams/.

How it works

Every edit on Spillway normally lands on the main line: a new revision becomes the entity's currentRevisionId, and a publish copies it to publishedRevisionId. A release keeps a request's changes apart from the main line instead:

  • Send the header X-Spillway-Release: <id or key> on any /api/* request (the admin's release switcher does this for every call while a release is selected). Preview URLs and API requests take ?release=<id> instead; on a public page ?release= is left to the page (a listing filter may use it), while ?workstream= still works there. When a request sends both the new and the old header, X-Spillway-Release wins.
  • PUT /api/entities/:id then writes a revision tagged with the release (Revision.workstreamId). The main line keeps its own current revision, title and data; nothing changes on the public site. The release's first edit of an entity records the main line's current revision id as the base.
  • POST /api/entities creates an entity that only the release can see (Entity.workstreamId); it keeps its path reserved but the main line never lists it and the public site never serves it until the merge.
  • DELETE /api/entities/:id records a delete intent; the entity stays live until the merge.
  • PUT /api/menus/:id and PUT /api/site are stored as overlays: what the release changed, applied on top of the main-line row whenever the release reads them. Creating or deleting a menu, changing a menu key, or changing an entity's slug or locale is not possible inside a release (do it on the main line).
  • Publish, unpublish, submit for review and restore answer 409 workstream_scope inside a release: the merge is how a release reaches the live site.

Reads follow the header too: GET /api/entities/:id returns the release's version with inWorkstream: true and workstreamAction (update, create or delete); GET /api/entities lists the main line plus the entities created in the release and marks the ones the release changes; /checks runs on the release's version; /revisions adds the release's revisions to the main-line list; GET /api/menus and GET /api/site return the overlaid versions. Without the header, none of this is visible.

A release has a status: open (editable), merging (inside the merge transaction), merged or discarded (both read-only; the record stays for the audit trail and can be deleted).

Preview

GET /_preview/ws/:id/<path> renders the site as it would look after the merge: the release's revisions for the pages it edits, the pages it creates, nothing (404) for its delete intents, the published snapshot for everything else, with the release's menus and settings around it. /_preview/ws/:id/ is the home page. Internal links are rewritten to stay inside the preview, so a reviewer can browse the whole campaign. Responses are Cache-Control: no-store, X-Spillway-Cache: BYPASS, X-Robots-Tag: noindex, carry the release id in X-Spillway-Release (and in X-Spillway-Workstream, the former name) and a noindex meta tag; the page cache is never read or written.

The preview needs a session with content.view_drafts, or a share link: POST /api/releases/:id/share-link returns { token, url, expiresAt }, a token valid for WORKSTREAM_SHARE_DAYS (7 days) that a stakeholder appends as ?token= without signing in. Only the sha256 of the token is stored; the rewritten links carry it along. A share token opens nothing but that release's preview.

/_preview/:entityId?release=<id> previews one entity's version in the release (the page builder's preview button uses this when a release is selected).

Diff and conflicts

GET /api/releases/:id/diff returns WorkstreamDiff:

  • items[]: one row per change with kind (entity, menu, settings), targetId, title, action, fieldChanges (fields whose value differs between the base revision and the release's revision, from content/diff.ts), layoutChanges (blocks added, removed or changed in the page builder document), checks (the pre-publish checks run on the release's version, review/checks.ts), and for entities revisionId, baseRevisionId and path. The full field-level diff of an entity is GET /api/entities/:id/revisions/:baseRevisionId/diff/:revisionId (send the release header so the release's revision is in scope).
  • conflicts[]: { kind, targetId, reason } where live_changed means the main line moved since the release changed the item (the entity's current revision id, or the menu's or settings' updatedAt, differs from the recorded base) and deleted means the target no longer exists.

Merge

POST /api/releases/:id/merge with an optional { "force": true } (permission content.publish):

  1. Refuses with 409 workstream_conflicts (details: the conflicts) when any item conflicts, unless force.
  2. Refuses with 422 checks_failed (details: { targetId, title, checks }[]) when any item has an error-level check, unless force.
  3. In one transaction: for each entity item, writes a new main-line revision from the release's revision (note Merged from release "<label>"), updates references, taxonomy rows and the search index, and publishes it through the same path as POST /api/entities/:id/publish (status published, entity.update and entity.publish audit rows, cache tags evicted, entity.published notification, module entity.afterSave/afterPublish hooks). An entity created in the release loses its workstreamId and becomes an ordinary published entity. A delete intent removes the entity (entity.delete). Menu and settings overlays are applied (menu.update, site.update, with the menu:<key>, site:<id> and theme tags evicted). The conflict check is repeated inside the transaction.
  4. Marks the release merged (mergedAt), clears publishAt, audits workstream.merge and queues the workstream.merged notification event.

A failure anywhere rolls the whole merge back. With force, a live_changed conflict is overwritten by the release's version and a deleted target is skipped. After the merge the release is read-only: edits inside it answer 409 workstream_read_only.

Discard, delete, schedule

  • POST /api/releases/:id/discard deletes the release's revisions, the entities created inside it, its items, overlays and share tokens, and marks it discarded. Nothing reaches the main line.
  • DELETE /api/releases/:id removes the record as well (discarding the changes first when the release is still open).
  • POST /api/releases/:id/schedule with { "publishAt": "<ISO date>" } (or null to clear; content.publish) merges it through the ticker: every SCHEDULE_TICK_MS (and on spillway run-scheduled) due releases are merged with force: false by the system actor. A refused merge (conflicts, checks) is logged, audited as workstream.merge_failed once per distinct error, recorded in lastMergeError and retried every tick until it merges; fixing the content inside the release is enough.

Permissions

ActionWho
List and read releases, diff, previewcontent.view_drafts
Createcontent.create (the creator owns it)
Rename, discard, delete, share linkthe owner, or anyone with content.publish
Edit content inside a releasethe usual rules: content.edit_any, or content.edit_own on your own content; menus and settings need site.settings
Merge, schedulecontent.publish

An author can therefore prepare a whole campaign in a release, share the preview, and hand it to an editor to merge.

Audit and notifications

Audit rows: workstream.create, workstream.update, workstream.schedule / workstream.unschedule, workstream.share_link, workstream.merge, workstream.merge_failed, workstream.discard, workstream.delete; workstream.item.create / workstream.item.update / workstream.item.delete on the entity, menu or site target for every edit inside a release; and, at merge, the ordinary entity.update, entity.publish, entity.delete, menu.update and site.update rows (their after carries workstreamId). The scheduler writes scheduler.workstreams per pass that did something.

The notification event workstream.merged (shown as "Release merged" in the rules under Site settings → Notifications) carries data.workstream: { id, key, label, itemCount, adminUrl } and the actor.

Storage

Migration 0020_workstreams (the tables keep the feature's former name):

  • workstream: id, site_id, key (unique per site), label, description, status, owner_id, publish_at, created_at, updated_at, merged_at, last_merge_error.
  • workstream_item: (workstream_id, kind, target_id) with revision_id (entities), action, base_version (the main line's current revision id, or updated_at for menus and settings, when the release first changed the item), updated_at, author_id.
  • workstream_overlay: (workstream_id, kind, target_id) → data JSON (menu: the changed label/items; settings: the accumulated PUT /api/site patch).
  • workstream_share_token: sha256 id, release, creator, expiry.
  • revision.workstream_id (a release's revisions; never pruned by the revision-history trim, deleted with the release) and entity.workstream_id (entities created in a release).

Limits

  • Collections, calendars, search and term pages inside the preview list the main line's published entities; a page the release creates appears in them only after the merge.
  • Slugs, locales, menu keys and new or deleted menus are main-line changes.
  • Releases hold content, not structure: content types, vocabularies, forms, redirects and media are shared with the main line.
  • Two releases may change the same entity; the second to merge conflicts (its base no longer matches) and needs force.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?