Skip to content

Editorial Workflow ​

This is an advanced remote workflow designed for teams that require a review process before changes are merged into the configured branch. Editors can submit changes for review, and designated reviewers can approve or request modifications.

Use Cases ​

  • Teams of content creators and editors working on collaborative projects.
  • Projects that require a formal review and approval process for content changes.
  • Situations where content quality and consistency are critical, necessitating oversight.
  • Workflows that involve multiple stages of review, such as draft, review, and publish.

Requirements ​

The GitHub, GitLab or Gitea/Forgejo backend must be used.

The workflow opens, labels, merges and closes a pull request for each entry, so a sign-in has to cover more than the repository contents:

  • Anyone signing in with a GitHub fine-grained personal access token needs the Pull requests permission on it as well as Contents. OAuth sign-in with the default repo scope already covers this.
  • On GitLab, a token with the api scope covers it.
  • On Gitea/Forgejo, the CMS asks for the read:issue and write:issue scopes on top of the repository ones, because labels live under the issue scope there. A token generated before the workflow was enabled doesn’t have them, so generate a new one.

Configuration ​

Add the publish_mode option to the top level of the CMS configuration file:

yaml
publish_mode: editorial_workflow
toml
publish_mode = "editorial_workflow"
json
{
  "publish_mode": "editorial_workflow"
}
js
{
  publish_mode: 'editorial_workflow',
}

The option accepts editorial_workflow or simple, the default. An empty string is treated as simple.

Enabling the Workflow per Collection ​

The publish_mode option can also be set on a collection, where it overrides the top-level setting. That lets you enable Editorial Workflow for the collections that need a review process and leave the rest in the simple mode, where a save is committed straight to the configured branch. The Editorial Workflow page is available as soon as one collection uses the workflow.

A typical case is a site whose blog posts are reviewed while the site settings are edited directly:

yaml
collections:
  - name: posts
    folder: content/posts
    publish_mode: editorial_workflow
  - name: settings
    folder: content/settings
    publish_mode: simple
toml
[[collections]]
name = "posts"
folder = "content/posts"
publish_mode = "editorial_workflow"

[[collections]]
name = "settings"
folder = "content/settings"
publish_mode = "simple"
json
{
  "collections": [
    {
      "name": "posts",
      "folder": "content/posts",
      "publish_mode": "editorial_workflow"
    },
    {
      "name": "settings",
      "folder": "content/settings",
      "publish_mode": "simple"
    }
  ]
}
js
{
  collections: [
    {
      name: 'posts',
      folder: 'content/posts',
      publish_mode: 'editorial_workflow',
    },
    {
      name: 'settings',
      folder: 'content/settings',
      publish_mode: 'simple',
    },
  ],
}

Either option can be omitted: a collection without its own publish_mode follows the top-level setting, which defaults to simple. So you can either enable the workflow site-wide and opt individual collections out, or leave the top level alone and opt individual collections in. Singletons always follow the top-level setting.

Notes

  • An entry that already has a pull request stays in the workflow until it’s published or discarded, even if its collection has since been switched to the simple mode. Saving it commits to the pull request rather than to the configured branch, so nothing that hasn’t been reviewed slips through.
  • With Open Authoring, the collection-level option only applies to maintainers who have write access to the repository. A contributor working on a fork always goes through a pull request, whatever the collection says.

How It Works ​

Nothing an editor does in the CMS touches the configured branch until the change is published. Each entry with unsaved work lives on its own branch with an open pull request, so making a change and releasing it are two separate steps.

Editor actionWhat happens in Git
Save a new entryA branch named cms/[COLLECTION_NAME]/[SLUG] is created off the configured branch, the entry files are committed to it, and a pull request is opened
Save an existing draftAnother commit is added to the same branch
Change the statusThe pull request’s label is updated
Delete a published entryA pull request is opened that removes the entry files
PublishThe pull request is merged and its branch is deleted
DiscardThe pull request is closed without merging and its branch is deleted

On GitLab the same applies, with merge requests in place of pull requests. Gitea and Forgejo call them pull requests, like GitHub.

With the root_dir option, the directory comes after cms/ in the branch name, e.g. cms/apps/blog/posts/hello-world, so the sites of a monorepo keep their branches apart.

Pull CMS Changes to Your Local Repository

Sveltia CMS commits changes to the remote repository, not to the copy on your computer. To see content published in the CMS on your local development server, run git pull first. Pulling before you make your own changes also helps avoid merge conflicts when you push. This doesn’t apply to the local development workflow, where the CMS writes to your local files instead.

Sharing a Branch ​

A workflow branch is named after the entry, not after the editor, so two people working on the same entry work on the same branch, and anyone with push access can commit to it.

  • Saving compares the branch with the draft first. If the entry has been changed on the branch since it was opened, a dialog says who changed it and when, and nothing is written until the user chooses Save Anyway. On GitHub, the commit also names the commit the entry was loaded or saved at, so a push that lands in the last moment makes GitHub refuse the save rather than have it built on top of something the user hasn’t seen. Gitea and Forgejo can’t be told which commit to build on, so the CMS checks that the branch still points at that commit right before saving, and refuses the save otherwise; trying again reads the entry back first. Each file the save changes is also checked against its version as of that commit, so a last-moment push that changes the same files is refused likewise. See Conflict Resolution.
  • Saving an entry that has no pull request yet onto a branch that already exists starts the branch over from the configured branch, so whatever an earlier pull request left on it — one merged without deleting the branch, or closed on GitHub, GitLab, Gitea or Forgejo rather than discarded in the CMS — isn’t carried into the new one. If a pull request is still open from that branch, though, the save is refused, with a message giving the pull request’s number. That’s a pull request the board doesn’t show: one that has lost its status label, one that goes to another branch, or one the CMS didn’t open. The CMS won’t commit onto it, because whatever else it holds would then be published along with the entry. Close it there, or, if it’s one the CMS opened, add its status label back to put it on the board again.
  • Publishing checks the pull request first; see Checks Before Publishing.

Only a pull request that goes from a branch of the repository to the configured branch is taken for an entry’s own, and only if it holds one of the entry’s files, or a file the entry had before it was renamed. If its base branch is changed to something else, the CMS stops treating it as the entry’s and leaves it alone, rather than relabeling or merging it in the entry’s name; the entry is then listed as it was before the pull request existed. Likewise, a pull request from the entry’s branch that holds a different entry isn’t offered in the entry’s editor.

Checks Before Publishing ​

The board and the editor show an entry, but publishing merges the whole pull request, with everything on its branch. So right before merging, the CMS reads the pull request again and publishes the entry only if:

  • the pull request still goes from a branch of the repository to the configured branch;
  • its branch still points at the commit the entry was loaded or saved at, so what is merged is what the user saw;
  • every file it changes is one the CMS accounts for: the entry’s own files, and those of its published version that a rename removes; assets added or replaced along with it, which are only removed when the entry is moved or deleted; the entries whose Relation references a rename or deletion rewrites; and the entries below a nested entry that move along with it. A file the merge would leave exactly as the configured branch already has it passes as well;
  • every file it leaves behind is a regular file, rather than a symbolic link or a Git submodule;
  • no file in an admin or cms folder is among its assets, as the CMS itself is usually served from there; see Read-Only Folders;
  • the list of files it changes is complete, rather than cut short by the Git service on a very large pull request.

Gitea and Forgejo work out the files a pull request changes in the background after each push, so publishing right after a save can take a few seconds while the CMS waits for the list to catch up with the latest commit. If the list still lags behind the branch when the board loads — on a busy instance with a backlog of work, say — the entry is shown as of the commit the list describes, so the board never mixes the files of one commit with the content of another. Publishing or saving it is then refused until the instance has caught up and the page is reloaded.

Otherwise the publish is refused, saying why. If the branch has moved on, the message asks the user to reload the page and review the latest version. If the pull request holds anything else — a change to the site’s code, a CI workflow, configuration, another entry — the message says it comes with changes the CMS can’t show, and it has to be reviewed and merged on GitHub, GitLab, Gitea or Forgejo instead, where every change it holds is visible.

Once the checks pass, the merge is pinned to the commit that was checked, so a push made in the meantime makes the merge fail rather than go along with it.

Saving and Sending for Review ​

Saving an entry doesn’t hand it to anyone — it stays a draft until someone moves it on. So when a user saves an entry that’s still in the Draft status, the CMS asks what to do next:

  • Send for Review moves the entry to In Review straight away, ready for someone to look at.
  • Later leaves it as a draft. It can be sent whenever the user likes, using the status button in the entry editor or by dragging its card between columns on the Editorial Workflow page.

The prompt only appears while an entry is still a draft. Saving one that’s already In Review or Ready leaves its status alone, and it’s withheld while the entry still has required fields to fill in, because there’s nothing worth handing over yet.

Required Fields ​

A draft is work in progress, so an entry in the Draft status can be saved with its required fields left empty. Nothing is marked as an error, and the entry keeps its pull request like any other draft.

Required fields are enforced as soon as the entry leaves the drafting stage. Moving it to In Review or Ready and publishing it are all refused while a required field is empty, in the entry editor and on the Editorial Workflow page alike, and the fields that need attention are marked so that they are easy to find.

Every other validation rule applies to a draft save as it always has: a value that breaks a pattern, minlength, min or max option is still rejected. Only being empty is excused, and only while the entry is a draft.

A draft can break your build

Saving a draft commits it to the workflow branch, so whatever builds that branch has to cope with the missing values. A framework that validates content against a schema — Astro content collections with Zod, for example — will fail on a field its schema requires, and the deploy preview for the pull request goes red until the entry is filled in. Nothing reaches the configured branch until the entry is published, so the production build is unaffected.

If that gets in the way, make the schema tolerant of drafts — .optional() or .nullable() on the fields in question — or keep those fields required in the CMS and fill them in before saving.

Statuses ​

An unpublished entry moves through three stages, shown as columns on the Editorial Workflow page and as a status button in the entry editor:

StatusLabelMeaning
Draftsveltia-cms/draftWork in progress
In Reviewsveltia-cms/pending_reviewReady for someone to look at
Readysveltia-cms/pending_publishApproved and ready to be merged

A pending deletion carries a fourth label, sveltia-cms/pending_deletion. It isn’t a stage — there’s no review to move it through, only the deletion itself to carry out or call off — so it doesn’t appear as a column. See Deleting Entries.

GitHub and GitLab create a label the first time it’s used. Gitea and Forgejo don’t, so the CMS creates each status label on the repository the first time an entry needs it. A label of the same name defined by the organization that owns the repository doesn’t count, because the CMS can only find pull requests by the repository’s own labels, so the repository gets its own label as well.

An entry in the Draft status is kept as a draft pull request or draft merge request, so it can’t be merged by accident. Moving the entry to In Review or Ready marks it ready for review.

The three backends record this differently:

BackendHow a draft is marked
GitHubA dedicated draft flag on the pull request
GitLabA Draft: prefix on the merge request title
Gitea/ForgejoA WIP: prefix on the pull request title

Sveltia CMS adds and removes the prefix automatically, so if you edit such a title by hand, keep the prefix intact while the entry is in the Draft status. Gitea and Forgejo refuse to merge a pull request whose title still carries the prefix, which is what keeps a draft from being published by accident there.

Custom Label Prefix ​

Labels are written with the sveltia-cms/ prefix by default. You can change it with the cms_label_prefix option in the backend section:

yaml
backend:
  name: github
  repo: user/repo
  cms_label_prefix: my-cms/
toml
[backend]
name = "github"
repo = "user/repo"
cms_label_prefix = "my-cms/"
json
{
  "backend": {
    "name": "github",
    "repo": "user/repo",
    "cms_label_prefix": "my-cms/"
  }
}
js
{
  backend: {
    name: 'github',
    repo: 'user/repo',
    cms_label_prefix: 'my-cms/',
  },
}

Migrating from Netlify/Decap CMS

Sveltia CMS reads the netlify-cms/ and decap-cms/ prefixes as well as the configured one, so pull requests created by Netlify CMS or Decap CMS show up straight away. Labels are always written with the configured prefix, so an imported pull request is migrated the first time its status changes.

Squash Merges ​

You can squash all the commits in a pull/merge request into a single commit when it’s merged by adding the squash_merges option to the backend section. Otherwise, a merge commit is created. This is supported with all three backends.

yaml
backend:
  name: github
  repo: user/repo
  squash_merges: true
toml
[backend]
name = "github"
repo = "user/repo"
squash_merges = true
json
{
  "backend": {
    "name": "github",
    "repo": "user/repo",
    "squash_merges": true
  }
}
js
{
  backend: {
    name: 'github',
    repo: 'user/repo',
    squash_merges: true,
  },
}

See the GitHub or GitLab documentation for more information about squash merging. On Gitea and Forgejo, the repository has to allow the Squash merge style, which is one of the merge styles in its pull request settings.

Editorial Workflow Page ​

A board with a column for each status is available from the top navigation. Drag a card from one column to another to change an entry’s status, or use the status button in the entry editor. Each card also offers the actions available at that stage, and clicking the card opens the entry in the editor.

Entry List ​

Unpublished entries appear in the entry list alongside published ones, each with a badge showing its status:

  • An entry that updates a published one replaces it in the list, so the user sees the pending version rather than what’s currently live.
  • An entry that has never been published is listed separately under an Unpublished Entries heading, above the published entries.

Deleting Entries ​

Deletion goes through review like any other change, so removing an entry from the configured branch is a two-step process. What the Delete button does depends on whether the entry has ever been published.

Deleting a Published Entry ​

Deleting a published entry opens a pull request that removes its files. The entry stays in the configured branch until that pull request is published. Until then it appears in the entry list and on the Editorial Workflow page with a Pending Deletion badge.

Because there’s nothing to review or edit, a pending deletion doesn’t move through the three stages. It carries the sveltia-cms/pending_deletion label rather than one of the stage labels, so it’s never mistaken for content waiting to go live — including by another CMS reading the same repository. It’s listed in its own section below the board, and its card offers two actions:

  • Cancel closes the pull request and leaves the entry in place.
  • Delete merges the pull request, which removes the entry.

Opening a pending deletion in the entry editor shows its content for reference only. The fields are read-only and there’s no Save button, because the only things left to do are carrying the deletion out or calling it off.

Different from Decap CMS

Decap CMS has a separate Unpublish action, and its Delete button removes the entry from the configured branch immediately. Sveltia CMS has no Unpublish action: deleting a published entry is the unpublish process, so making the change and releasing it stay separate, the same as with any edit. See issue #770.

Deleting an Unpublished Entry ​

  • If the entry has never been published, deleting it closes its pull request. Nothing is left behind, because nothing was ever merged into the configured branch.
  • If the entry updates a published one, the button is labeled Discard instead. Discarding closes the pull request and restores the published version, which stays in the configured branch. The entry itself isn’t deleted.

Restricting Publishing and Deletion ​

Two collection options let you limit what editors can do. Both are set on the collection, not on the backend:

  • publish: false hides the publishing controls, so editors can move an entry through the review stages but someone else has to publish it.
  • delete: false prevents entries from being deleted. Discarding unpublished changes is still allowed, because that leaves the published version untouched.
yaml
collections:
  - name: posts
    folder: content/posts
    publish: false
    delete: false
toml
[[collections]]
name = "posts"
folder = "content/posts"
publish = false
delete = false
json
{
  "collections": [
    {
      "name": "posts",
      "folder": "content/posts",
      "publish": false,
      "delete": false
    }
  ]
}
js
{
  collections: [
    {
      name: 'posts',
      folder: 'content/posts',
      publish: false,
      delete: false,
    },
  ],
}

Event Hooks ​

Editorial Workflow adds four event types on top of preSave and postSave:

EventWhen it fires
prePublishBefore a pull request is merged
postPublishAfter a pull request has been merged
preUnpublishBefore a published entry is removed from the configured branch
postUnpublishAfter a published entry has been removed from the configured branch

The preUnpublish and postUnpublish hooks fire when a deletion is published, not when it’s requested — that’s the point at which the entry actually leaves the configured branch. Publishing a deletion fires these instead of prePublish and postPublish, because nothing is being published.