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-Releasewins. PUT /api/entities/:idthen 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/entitiescreates 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/:idrecords a delete intent; the entity stays live until the merge.PUT /api/menus/:idandPUT /api/siteare 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_scopeinside 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 withkind(entity,menu,settings),targetId,title,action,fieldChanges(fields whose value differs between the base revision and the release's revision, fromcontent/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 entitiesrevisionId,baseRevisionIdandpath. The full field-level diff of an entity isGET /api/entities/:id/revisions/:baseRevisionId/diff/:revisionId(send the release header so the release's revision is in scope).conflicts[]:{ kind, targetId, reason }wherelive_changedmeans 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) anddeletedmeans the target no longer exists.
Merge
POST /api/releases/:id/merge with an optional { "force": true } (permission content.publish):
- Refuses with
409 workstream_conflicts(details: the conflicts) when any item conflicts, unlessforce. - Refuses with
422 checks_failed(details:{ targetId, title, checks }[]) when any item has an error-level check, unlessforce. - 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 asPOST /api/entities/:id/publish(statuspublished,entity.updateandentity.publishaudit rows, cache tags evicted,entity.publishednotification, moduleentity.afterSave/afterPublishhooks). An entity created in the release loses itsworkstreamIdand becomes an ordinary published entity. A delete intent removes the entity (entity.delete). Menu and settings overlays are applied (menu.update,site.update, with themenu:<key>,site:<id>andthemetags evicted). The conflict check is repeated inside the transaction. - Marks the release
merged(mergedAt), clearspublishAt, auditsworkstream.mergeand queues theworkstream.mergednotification 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/discarddeletes the release's revisions, the entities created inside it, its items, overlays and share tokens, and marks itdiscarded. Nothing reaches the main line.DELETE /api/releases/:idremoves the record as well (discarding the changes first when the release is still open).POST /api/releases/:id/schedulewith{ "publishAt": "<ISO date>" }(ornullto clear;content.publish) merges it through the ticker: everySCHEDULE_TICK_MS(and onspillway run-scheduled) due releases are merged withforce: falseby thesystemactor. A refused merge (conflicts, checks) is logged, audited asworkstream.merge_failedonce per distinct error, recorded inlastMergeErrorand retried every tick until it merges; fixing the content inside the release is enough.
Permissions
| Action | Who |
|---|---|
| List and read releases, diff, preview | content.view_drafts |
| Create | content.create (the creator owns it) |
| Rename, discard, delete, share link | the owner, or anyone with content.publish |
| Edit content inside a release | the usual rules: content.edit_any, or content.edit_own on your own content; menus and settings need site.settings |
| Merge, schedule | content.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)withrevision_id(entities),action,base_version(the main line's current revision id, orupdated_atfor menus and settings, when the release first changed the item),updated_at,author_id.workstream_overlay:(workstream_id, kind, target_id)→dataJSON (menu: the changedlabel/items; settings: the accumulatedPUT /api/sitepatch).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) andentity.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