Editorial Workflows

Cookbook: Coordinated release

A release workflow that coordinates approvals across many documents and hands the atomic go-live to a Content Release.

Prerelease

Coordinate review across every document in a Content Release, then hand the atomic go-live to Sanity. This recipe defines the review child, the parent workflow, and the Functions that drive them.

The release workflow runs in four stages:

Loading...
  • Assembly: the documents are grouped into the release; a review subworkflow spawns for each one, and a guard holds every member document’s publish until its review passes (for what a guard enforces today, see Guards and enforcement).
  • Scheduled: a release manager schedules the embargoed go-live, or publishes on demand.
  • Published: the release publishes as one unit; effects fire the build and notifications.
  • Archived: terminal, with revert as the rollback.

Before you get started

  • Packages: @sanity/workflow-engine, @sanity/client (the release helpers live on client.releases.*), and @sanity/functions + @sanity/blueprints for the runtime.
  • A Content Release: created up front, with a version document per member (client.releases.create + client.createVersion). This workflow coordinates the review and the timed go-live; it does not create the release. See Content Releases with @sanity/client.
  • An editor token: for the release dataset, plus the engine’s own client and a workflows dataset for its instances.
  • A release-manager role in dataset access control (custom roles): the schedule action is gated to it (advisory in the engine; dataset access control independently governs what each token may actually write).

The workflow takes two definitions: a small review child spawned once per document, and the parent that coordinates them. The release and member documents are supplied as global document references so each keeps its resource identity.

Define the review child

The review child is a spawn-only (lifecycle: 'child') workflow: one document, one reviewer, approve or request changes.

Loading...
import {
  defineWorkflow,
  defineField,
  defineStage,
  defineActivity,
  defineAction,
  defineTransition,
  defineGuard,
} from '@sanity/workflow-engine/define'

export const releaseReview = defineWorkflow({
  name: 'release-review',
  title: 'Release document review',
  lifecycle: 'child', // spawn-only: a parent instantiates it, never started cold
  initialStage: 'review',
  fields: [
    defineField({type: 'subject', name: 'subject', initialValue: {type: 'input'}, required: true}),
    defineField({type: 'actor', name: 'reviewer'}),
    defineField({type: 'actor', name: 'approval'}),
    defineField({type: 'string', name: 'changeNote'}),
  ],
  stages: [
    defineStage({
      name: 'review',
      title: 'Review',
      // Per-document publish hold: a guard's `idRefs` resolves to ONE document, so the
      // hold lives here on the child's own `subject`, not on the parent over the whole
      // `documents` array (an array idRef resolves to nothing and deploys no guard).
      // idRefs are typed GuardReads, resolved at deploy and re-synced by the guard refresh.
      guards: [
        defineGuard({
          name: 'hold-publish-review',
          title: 'Hold publish while under review',
          match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['publish']},
        }),
      ],
      activities: [
        defineActivity({
          name: 'review',
          title: 'Review the document',
          actions: [
            defineAction({type: 'claim', name: 'claim', title: 'Take this review', field: 'reviewer'}),
            defineAction({
              name: 'approve',
              title: 'Approve',
              filter: '$fields.reviewer.id == $actor.id',
              status: 'done',
              ops: [{type: 'field.set', target: {field: 'approval'}, value: {type: 'actor'}}],
            }),
            defineAction({
              name: 'request-changes',
              title: 'Request changes',
              filter: '$fields.reviewer.id == $actor.id',
              status: 'done',
              params: [{type: 'string', name: 'note', title: 'What to change', required: true}],
              ops: [{type: 'field.set', target: {field: 'changeNote'}, value: {type: 'param', param: 'note'}}],
            }),
          ],
        }),
      ],
      transitions: [
        defineTransition({name: 'approved', to: 'approved', when: 'defined($fields.approval)'}),
        defineTransition({name: 'changes', to: 'changes-requested', when: 'defined($fields.changeNote)'}),
      ],
    }),
    defineStage({
      name: 'changes-requested',
      title: 'Changes requested',
      // Still the team's; keep the publish hold until the document is approved.
      guards: [
        defineGuard({
          name: 'hold-publish-changes',
          title: 'Hold publish while changes are pending',
          match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['publish']},
        }),
      ],
      activities: [
        defineActivity({
          name: 'fix',
          title: 'Address the changes',
          actions: [
            defineAction({
              name: 'resubmit',
              title: 'Resubmit for review',
              status: 'done',
              // clear the note before the move so the review stage's `changes` transition doesn't refire on re-entry
              ops: [{type: 'field.unset', target: {field: 'changeNote'}}],
            }),
          ],
        }),
      ],
      transitions: [
        defineTransition({name: 'back-to-review', to: 'review', when: '$allActivitiesDone'}),
      ],
    }),
    defineStage({name: 'approved', title: 'Approved'}), // terminal: the parent reads this as "cleared for release"
  ],
})

Define the release workflow

The parent coordinates the reviews and the timed go-live in one defineWorkflow call:

import {
  defineWorkflow,
  defineField,
  defineStage,
  defineActivity,
  defineAction,
  defineTransition,
} from '@sanity/workflow-engine/define'

export const coordinatedRelease = defineWorkflow({
  name: 'coordinated-release',
  title: 'Coordinated release',
  initialStage: 'assembly',
  // Refuse another unfinished run of this definition for the same release.
  start: {
    requirements: [
      {
        type: 'groq',
        name: 'one-open-release',
        title: 'Release workflow already in progress',
        query:
          "count(*[_type == 'sanity.workflow.instance' && tag == $tag && definition == $definition" +
          " && !defined(completedAt) && fields[name == 'release'][0].value.releaseName == $fields.release.releaseName]) == 0",
      },
    ],
  },
  fields: [
    defineField({type: 'release.ref', name: 'release', initialValue: {type: 'input'}, required: true}),
    defineField({type: 'doc.refs', name: 'documents', initialValue: {type: 'input'}, required: true}),
    defineField({type: 'datetime', name: 'publishAt'}),
  ],
  stages: [
    defineStage({
      name: 'assembly',
      title: 'Assembly',
      // The publish hold is PER-DOCUMENT, on each release-review child over its single
      // `subject` (see above), NOT a parent guard over the `documents` array. A guard's
      // `idRefs` resolves to one document, so an array target deploys no guard at all.
      activities: [
        defineActivity({
          name: 'reviews',
          title: 'Review every document',
          actions: [
            defineAction({
              name: 'open-reviews',
              when: 'true', // entry trigger: fires on the first cascade after stage entry
              spawn: {
                // GDR rows self-key on their id, so fan out over the doc.refs field directly.
                forEach: '$fields.documents[]',
                definition: {name: 'release-review'},
                with: {subject: '$row'}, // each child reviews one document
              },
            }),
            // Declared after the spawn: triggers run in declaration order, so the entry
            // cascade opens the reviews before this gate first evaluates.
            defineAction({
              name: 'all-reviewed',
              when: "count($subworkflows[activity == 'reviews' && current && status == 'active']) == 0",
              status: 'done',
            }),
          ],
        }),
      ],
      transitions: [defineTransition({name: 'to-scheduled', to: 'scheduled', when: '$allActivitiesDone'})],
    }),
    defineStage({
      name: 'scheduled',
      title: 'Scheduled',
      activities: [
        defineActivity({
          name: 'schedule',
          title: 'Schedule the release',
          actions: [
            defineAction({
              name: 'schedule',
              title: 'Schedule go-live',
              roles: ['release-manager'],
              params: [{type: 'dateTime', name: 'publishAt', title: 'Go-live', required: true}],
              status: 'done',
              ops: [{type: 'field.set', target: {field: 'publishAt'}, value: {type: 'param', param: 'publishAt'}}],
              effects: [
                {name: 'schedule-release', bindings: {release: '$fields.release', publishAt: '$fields.publishAt'}},
              ],
            }),
          ],
        }),
        defineActivity({
          name: 'go-live',
          title: 'Await go-live',
          // off-system: the publish happens in Content Releases, and `target` deep-links
          // the release; a scheduled Function fires `mark-published` once it has published
          target: {type: 'field', field: 'release'},
          actions: [defineAction({name: 'mark-published', title: 'Mark released', status: 'done'})],
        }),
      ],
      transitions: [defineTransition({name: 'to-published', to: 'published', when: '$allActivitiesDone'})],
    }),
    defineStage({
      name: 'published',
      title: 'Published',
      activities: [
        defineActivity({
          name: 'announce',
          title: 'Build and notify',
          actions: [
            defineAction({
              name: 'queue-announcements',
              when: 'true', // entry trigger: queues both effects the moment the stage opens
              effects: [{name: 'build-site'}, {name: 'notify-stakeholders'}],
            }),
            defineAction({
              name: 'confirm-announced',
              when:
                "$effectStatus['build-site'] == 'done' && $effectStatus['notify-stakeholders'] == 'done'",
              status: 'done',
            }),
            defineAction({
              name: 'announcement-failed',
              when:
                "$effectStatus['build-site'] == 'failed' || $effectStatus['notify-stakeholders'] == 'failed'",
              status: 'failed',
            }),
          ],
        }),
      ],
      transitions: [defineTransition({name: 'to-archived', to: 'archived', when: '$allActivitiesDone'})],
    }),
    defineStage({name: 'archived', title: 'Archived'}), // terminal
  ],
})
Loading...

What each piece does

  • Inputs: release identifies the Content Release; documents lists the members that need review.
  • Duplicate starts: the one-open-release requirement rejects another unfinished parent instance for the same release. Spawned child workflows do not evaluate parent start requirements.
  • Review fan-out: open-reviews spawns one release-review child per document. all-reviewed completes the parent activity when no child in the current cohort remains active.
  • Per-document hold: each child places the guard on its own subject, because a guard targets one document rather than the parent’s document array. The hold remains until that review is approved.
  • Scheduling: the release manager supplies publishAt. The schedule-release effect schedules the Content Release, and a scheduled Function later confirms publication by firing mark-published.
  • After publication, the workflow runs the build and notification effects. Both must succeed before it archives; a failure remains visible in Published.

Write the effect handlers

Keep the shared client and each external concern in a small file. The registry maps the effect names in the definition to those handlers. Effect delivery is at-least-once, so every handler below gives retries a stable outcome.

Shared content client

// handlers/content.ts
import {createClient} from '@sanity/client'

export const content = createClient({
  projectId: 'yourprojectid',
  dataset: 'production',
  apiVersion: '2025-02-19',
  token: process.env.SANITY_EDITOR_TOKEN!,
  useCdn: false,
})

Release scheduling

// handlers/schedule-release.ts
import {type EffectHandler, type ReleaseRef} from '@sanity/workflow-engine'
import {content} from './content'

export const scheduleRelease: EffectHandler = async (params) => {
  const release = params.release as ReleaseRef
  const publishAt = params.publishAt as string
  const current = await content.releases.get({releaseId: release.releaseName})

  if (current?.state === 'scheduled' && current.publishAt === publishAt) return

  await content.releases.schedule({
    releaseId: release.releaseName,
    publishAt,
  })
}

Site build

Use the stable effect key as the receiving system’s idempotency key. The deployment hook must honor this header.

// handlers/build-site.ts
import {type EffectHandler} from '@sanity/workflow-engine'

export const buildSite: EffectHandler = async (_params, ctx) => {
  const response = await fetch(process.env.DEPLOY_HOOK_URL!, {
    method: 'POST',
    headers: {'Idempotency-Key': ctx.effectKey},
  })

  if (!response.ok) {
    throw new Error(`Build hook failed: ${response.status}`)
  }
}

Stakeholder notification

Write a deterministic outbox record. A separate worker can deliver the Slack message or email and mark the record complete.

// handlers/notify-stakeholders.ts
import {type EffectHandler} from '@sanity/workflow-engine'
import {content} from './content'

export const notifyStakeholders: EffectHandler = async (_params, ctx) => {
  await content.createIfNotExists({
    _id: `workflow-notification.${ctx.effectKey}`,
    _type: 'workflow.notification',
    instanceId: ctx.instanceId,
    status: 'pending',
  })
}

Handler registry

// handlers/effect-handlers.ts
import {type EffectHandler} from '@sanity/workflow-engine'
import {buildSite} from './build-site'
import {notifyStakeholders} from './notify-stakeholders'
import {scheduleRelease} from './schedule-release'

export const effectHandlers: Record<string, EffectHandler> = {
  'schedule-release': scheduleRelease,
  'build-site': buildSite,
  'notify-stakeholders': notifyStakeholders,
}

Learn more in Content Releases with @sanity/client.

Configure the engine

Create the engine with the workflow dataset client, the effect handlers, and a resourceClients route for the production dataset that holds the release and member documents.

// engine.ts
import {createClient} from '@sanity/client'
import {createEngine, ENGINE_API_VERSION, type ParsedGdr} from '@sanity/workflow-engine'
import {effectHandlers} from './handlers/effect-handlers'
import {content} from './handlers/content'

const workflowClient = createClient({
  projectId: 'yourprojectid',
  dataset: 'workflows',
  apiVersion: ENGINE_API_VERSION,
  token: process.env.SANITY_AUTH_TOKEN,
  useCdn: false,
})

export const engine = createEngine({
  client: workflowClient,
  workflowResource: {type: 'dataset', id: 'yourprojectid.workflows'},
  tag: 'prod',
  effectHandlers,
  resourceClients: (parsed: ParsedGdr) =>
    parsed.scheme === 'dataset' && parsed.dataset === 'production' ? content : undefined,
})

Drive it with Functions

Two Functions drive the release: one reacts to instance changes, and one checks the scheduled go-live.

An instance-change Function

The instance-change Function drains effects and ticks the instance. After publication, both announcement effects must succeed before the workflow archives.

// functions/advance-release/index.ts
import {documentEventHandler} from '@sanity/functions'
import {engine} from '../../engine'

export const handler = documentEventHandler(async ({event}) => {
  await engine.drainEffects({instanceId: event.data._id})
  await engine.tick({instanceId: event.data._id})
})

A scheduled Function for the go-live

The engine keeps no clock. A scheduled Function checks releases waiting at go-live and fires mark-published after Sanity reports the release as published:

// functions/confirm-go-live/index.ts
import {engine} from '../../engine'
import {content} from '../../handlers/content'

export const handler = async () => {
  const waiting = await engine.query<{_id: string; releaseName: string}[]>({
    groq: `*[_type == "sanity.workflow.instance" && tag == $tag && definition == "coordinated-release" && currentStage == "scheduled" && !defined(completedAt)]{
      _id, "releaseName": fields[name == "release"][0].value.releaseName
    }`,
  })
  for (const wf of waiting) {
    const release = await content.releases.get({releaseId: wf.releaseName})
    if (release?.state === 'published') {
      await engine.fireAction({
        instanceId: wf._id,
        activity: 'go-live',
        action: 'mark-published',
        idempotent: true,
      })
    }
  }
}

Learn more in Functions and Create a Scheduled Function.

Deploy it

First, deploy both workflow definitions: the parent and its spawn-only child (the child must be deployed for the parent to spawn it). The CLI reads a sanity.workflow.ts:

Before setting expectedMinReaderModel, review Reader-model acknowledgement.

// sanity.workflow.ts
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {coordinatedRelease} from './coordinated-release'
import {releaseReview} from './release-review'

export default defineWorkflowConfig({
  deployments: [
    {
      name: 'production',
      tag: 'prod',
      expectedMinReaderModel: 4,
      workflowResource: {type: 'dataset', id: 'yourprojectid.workflows'},
      definitions: [releaseReview, coordinatedRelease],
    },
  ],
})

Then log in and run the deploy:

sanity login && sanity-workflows deploy

Deploying a changed definition creates a new version. See Set up the workflow CLI for sharing and deployment options.

Add the instance-change Function and scheduled Function to the blueprint:

// sanity.blueprint.ts
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'advance-release',
      event: {
        on: ['create', 'update'],
        filter: '_type == "sanity.workflow.instance"',
        resource: {type: 'dataset', id: 'yourprojectid.workflows'},
      },
    }),
    // plus a scheduled (cron) function resource for `confirm-go-live`;
    // see the Create a Scheduled Function quick start for its blueprint shape
  ],
})

Then add the Functions to the blueprint and deploy them:

The document Function advances instance-driven work; the scheduled Function confirms go-live.

Run it

Start an instance with the release and its member documents. The one-open-release requirement prevents another unfinished run for that Content Release:

import {refDataset, releaseRef} from '@sanity/workflow-engine'
import {engine} from './engine'

const release = releaseRef({res: {type: 'dataset', id: 'yourprojectid.production'}, releaseName: 'spring-launch'})

// The definition's start requirement prevents another unfinished run for this release.

// verbs return an OperationResult; the instance document is on .instance
const {instance: wf} = await engine.startInstance({
  definition: 'coordinated-release',
  initialFields: [
    {type: 'release.ref', name: 'release', value: release},
    {
      type: 'doc.refs',
      name: 'documents',
      value: [
        refDataset({projectId: 'yourprojectid', dataset: 'production', documentId: 'landing', type: 'page'}),
        refDataset({projectId: 'yourprojectid', dataset: 'production', documentId: 'pricing', type: 'page'}),
        refDataset({projectId: 'yourprojectid', dataset: 'production', documentId: 'launch-post', type: 'article'}),
      ],
    },
  ],
})

// One release-review child spawns per document; reviewers claim and approve each
// (see the Editorial review example for the per-review calls). Once every child
// is approved, the workflow moves to 'scheduled'. Then a release manager:
await engine.fireAction({
  instanceId: wf._id,
  activity: 'schedule',
  action: 'schedule',
  params: {publishAt: '2026-07-01T09:00:00Z'},
})

// A scheduled Function then confirms the go-live: once Sanity publishes the release
// it fires `mark-published`, advancing the workflow to 'published'.

One review child spawns per document. Once every child is approved, the workflow moves to Scheduled. At go-live the release publishes as one unit. The build and notification then run; both must succeed before the workflow archives.

A manager starts the release and schedules its go-live; reviewers approve child workflows through engine actions. Functions drain the schedule effect and confirm publication. The engine owns the transitions from Assembly to Scheduled, Published, and Archived; the Content Lake persists each state.

Loading...

Adapting it

  • Publish on demand instead of scheduling: drop the publishAt param and have the effect call client.releases.publish immediately.
  • Reuse the editorial example: if each document is written fresh for the launch, use it as the child rather than the review-only child here.
  • Predicated guards: the holds here are unconditional denies. Give a guard a predicate (Content Lake GROQ over the mutation) to allow specific writes while the hold stands, for example permitting metadata patches on a member document while still denying publish.
  • Cardinality: a single-document release is the same machine with one child. Sanity’s Scheduled Drafts are exactly that.

Next steps

Visiting agent?

Was this page helpful?