Workflows as code¶
A workflow is a YAML document: statuses, the transitions between them, and the rules that apply when an issue moves. Each issue type in a project uses one workflow. Project templates create sensible defaults; change them in the visual editor (Project settings → Workflows), in YAML in the same editor, or from CI with the CLI.
kind: Workflow # optional
name: Software development
description: To Do → In Progress → In Review → Done
statuses:
- { name: To Do, category: todo } # the first status is the initial one,
- { name: In Progress, category: in_progress } # unless another has `initial: true`
- { name: In Review, category: in_progress }
- { name: Done, category: done }
transitions:
- name: Start work
from: [To Do]
to: In Progress
postFunctions: [{ assignTo: actor }]
- { name: Submit for review, from: In Progress, to: In Review }
- name: Done
from: [In Progress, In Review]
to: Done
condition: 'actor.hasRole("member") || actor.hasRole("admin")'
validators:
- expr: 'issue.subtasks.all(s, s.category == "done")'
message: Finish every sub-task first.
screen:
- { field: resolution, required: true }
- comment
- { name: Reopen, from: Done, to: To Do }
- { name: Won't do, to: Done } # no `from`: available from any status
layout: # node positions for the visual editor
To Do: { x: 0, y: 120 }
Statuses¶
| Key | |
|---|---|
name |
Up to 60 characters. Statuses are shared across workflows by name, so "In Review" is the same status everywhere (boards and searches rely on this). A name must always have the same category. |
category |
todo, in_progress or done. Categories drive boards, progress bars, reports and resolution: entering a done status sets the resolution (to Done unless a screen or post-function chose another), leaving one clears it. |
initial |
Optional; marks the status new issues start in (otherwise the first). |
At most 50 statuses.
Transitions¶
| Key | |
|---|---|
name |
Shown on buttons and in history. |
id |
Optional; defaults to the slug of the name (Start work → start-work). API clients and smart commits use it. |
from |
A status name or a list. Omit it for a global transition, available from every other status. |
to |
Target status. |
condition |
CEL expression; when false the transition is hidden and refused (HTTP 403). |
validators |
CEL expressions (a string, or {expr, message}) checked after the screen is filled; failures answer 422 validation_failed with the messages. |
screen |
Fields to ask for when the transition runs: a field name or {field, required}. Built-in fields: resolution, comment, assignee, priority, storyPoints, dueDate, labels, fixVersions; any custom field key. A transition whose screen has required fields cannot run from a drag on the board without the dialog (422 screen_required). |
postFunctions |
Run after the move, in order. Exactly one key each: |
Post-functions:
| Runs | ||
|---|---|---|
assignTo: actor \| reporter \| unassigned \| <email> |
in the transaction | |
setField: {field, value} |
in the transaction | priority, storyPoints, dueDate, labels, assignee, resolution or a custom field key |
clearField: <field> |
in the transaction | |
notify: assignee \| reporter \| watchers |
as a background job | in-app notification, emailed per the recipients' settings |
webhook: <url> |
as a background job | POST of the issue as JSON (unsigned — use a webhook subscription when the receiver needs to verify the sender) |
CEL reference¶
Conditions, validators and automation conditions are CEL expressions with a cost limit. Variables:
| Variable | Fields |
|---|---|
issue |
key, project, type (key), typeLevel, summary, description, priority, status, statusCategory, assignee and reporter (emails, "" when unset), labels, storyPoints, originalEstimate, remainingEstimate and timeSpent (seconds, 0 when unset), resolution, startDate, dueDate (YYYY-MM-DD or ""), fields (custom fields by key), subtasks / children (list of {key, summary, type, status, category}), parent ({key, type, status, category, childCount, openChildren, startDate, dueDate} or {}; openChildren counts the parent's children not in a done status), links (list of {type, direction, key, category}), devLinks (list of {kind: branch\|commit\|pr\|pipeline, state, title, merged, repository}) |
actor |
id, email, name, type (user, automation, scm), orgRole, roles (project role keys), permissions |
transition |
id, name, from, to |
event |
automation triggers only: type and trigger data (e.g. from, to, fields, number, branch) |
now |
timestamp |
Functions: actor.hasRole("admin"), actor.hasPermission("issue.delete"), plus the standard
library and the strings, lists, sets and math extensions.
Examples:
issue.assignee != "" // someone must own it
issue.storyPoints > 0 // estimated before starting
issue.devLinks.exists(d, d.kind == "pr" && d.merged) // a merged PR is linked
!issue.links.exists(l, l.type == "blocks" && l.direction == "inward" && l.category != "done")
issue.type != "bug" || issue.fields.severity != "" // bugs need a severity
actor.email == issue.reporter || actor.hasRole("admin")
Automation and SCM actors (actor.type automation or scm) pass hasRole and
hasPermission checks; smart commits run as the mapped committer, so their own roles apply.
Versions and migration¶
Workflows are versioned. Publishing (editor, PUT /api/v1/workflows/apply, or the CLI)
creates version N+1; existing issues stay on the version they were created under until a
workflow_migrate job moves them. When the new version removes a status that issues still
use, publishing requires a status mapping ({"Ready for QA": "In Review"}); without it the
API answers 422 status_mapping_required with the statuses that need one. Validate first with
POST /api/v1/workflows/validate, which also reports removedStatuses.
Changing an issue type's workflow (Project settings → Issue types) works the same way: statuses that exist in both workflows carry over; others need a mapping.
The CLI¶
export WFM_SERVER=https://wfm.example.com
export WFM_TOKEN=wfm_pat_... # a token with the write scope (project admin)
wfm workflow validate -f workflows/dev.yaml
wfm workflow apply -f workflows/dev.yaml --project PLAT --dry-run
wfm workflow apply -f workflows/dev.yaml --project PLAT --map "Ready for QA=In Review"
wfm workflow export --id 0b6f… > workflows/dev.yaml
Examples to start from are in examples/workflows/: a code-review flow
that requires estimates and a linked pull request, and an incident flow.
apply is idempotent: it creates the workflow if the name is new in that scope, publishes a
version if the document changed, and does nothing (unchanged) otherwise — so it fits a GitOps
pipeline. Without --project it manages a shared workflow (organisation admins).