Changesets¶
A changeset is a content change that ships with code and runs exactly once
per environment: hook_update_N, for content.
drush content-deployment:status # what has run here, and what is waiting
drush content-deployment:run --dry-run # what the pending ones would do
drush content-deployment:run # run them
drush content-deployment:run --publish # run them and publish what they write
drush content-deployment:run --id=site:0003_opening_hours # one, out of order
Why not a committed spec¶
content-spec:apply is declarative: it empties a field the spec omits and
drops a paragraph the spec no longer names. A spec committed in March and
applied again in June reverts every editorial change made in between. A
changeset runs once, is recorded, and is never considered again. When the
page needs to change again, that ships as a later changeset, which takes the
content from wherever it has got to.
Where changesets live¶
An absolute path, or one relative to the Drupal root, outside the docroot.
Changesets are files in its changesets directory, named so they sort:
| Found in | Id |
|---|---|
<content_deployment_directory>/changesets/*.json |
site:<filename without extension> |
<module>/content/changesets/*.json, for each enabled module |
<module>:<filename without extension> |
A top-level "id" in a file replaces the derived id. A changeset with an
explicit id reads as applied wherever its file is, so it can move between
directories. Two files with the same id are both reported as broken and
neither runs.
Changesets run in order of file name first and source (site or the module
name) second, so numbering across sources gives the order written down.
Modules are inside the docroot, so the status report warns, naming them, when
an enabled module ships a content/changesets directory, and when
content_deployment_directory resolves inside the docroot. Without the
setting, only module changesets are found.
The file¶
A file with an entities or unpublish key is a changeset:
{
"id": "opening-hours-2026",
"description": "Opening hours for 2026.",
"publish": true,
"entities": [
{ "entity_type": "node", "bundle": "landing", "uuid": "0a8ba38b-c932-40ca-bd7f-f28a87686ac8", "title": "Opening hours" }
],
"unpublish": [
{ "entity_type": "node", "uuid": "5b9c7f4e-2a1d-4c3b-8e6f-9d0a1b2c3d4e", "title": "Opening hours 2025" }
]
}
| Key | Meaning |
|---|---|
id |
Optional. Replaces the derived id. |
description |
Shown by content-deployment:status. |
publish |
true publishes what the changeset writes. Default false. |
entities |
Content specs, written in order. An entry with a _base map is a patch: see Patches. |
unpublish |
Entities to take offline after entities are written, each {entity_type, uuid}. title is for the reader and is not checked. An entry may carry a _base: see Unpublishing with a base. |
files |
The files the changeset carries, by SHA-256: see Files. |
Anything else is read as a single spec, so drush content-spec:read 2 >
changesets/0002_front_page.json is a changeset as it stands. Its
description is the spec's title, and a bare spec cannot ask to be published.
Upsert¶
Each entity is looked up by uuid. One that resolves is updated, as
content-spec:apply does it; anything else is created, with the uuid from
the spec. content-spec:create and content-spec:apply stay strict, refusing
an existing or an unknown uuid; a changeset has said which it means by
shipping.
Unpublishing¶
Each unpublish entry is taken offline as content-spec:unpublish does it: the
workflow's archived state, an unpublished revision, or a blocked account.
Nothing is deleted. A uuid that does not resolve is reported as not here and
skipped, since a site built from the repository never had the content; one
already offline is reported as already offline and left alone.
Publishing¶
Everything a changeset writes is a draft unless the changeset says
"publish": true or the command is given --publish, which publishes
everything that run writes.
What is recorded¶
One record per changeset id in the key-value collection
content_deployment.changesets, in the environment's database:
| Key | Value |
|---|---|
applied |
When it ran, a Unix timestamp. |
hash |
The SHA-256 of the file as it ran. |
published |
Whether it published what it wrote. |
entities |
<entity_type>:<uuid> of each entity written. |
unpublished |
<entity_type>:<uuid> of each entity taken offline. |
conflicts |
Patches only, when there were any: the conflicting paths of each entity, and the reason for each unpublish skipped, by <entity_type>:<uuid>. |
flags |
Patches only, when there were any: what each entity's outcome needs a reviewer to know, by <entity_type>:<uuid>. |
note |
On the site a changeset was exported from: why it is recorded without having run. |
A database copied from another environment arrives knowing what has run there. Changesets are recorded individually, not as a high-water mark, so one merged from a branch after a higher number has landed still runs.
content-deployment:status lists every changeset found:
| Status | Meaning |
|---|---|
pending |
Not run here. |
applied |
Run here, and the file is as it was. |
EDITED |
Run here, and the file's SHA-256 no longer matches the record. It will not run again; ship the change as a new changeset. The command also logs a warning naming them. |
BROKEN |
The file cannot be run: it does not parse, has an invalid id, or shares its id with another file. |
A run stops at the first failure. Nothing is recorded for a changeset that failed, so fixing it and running again starts it from the top; an entity it had already written is an update the second time.
--id=<id> runs one changeset out of order, and refuses one that has already
run.
Making a spec worth committing¶
--portable names content references instead of identifying them by uuid,
wherever the name is unambiguous within the bundles the field accepts. A uuid
means one entity on one database; a name travels to a site built from the same
repository. Where a name is ambiguous, the uuid is kept.
--structure-only omits the prose and keeps the shape: which components, in
what order, with which settings. Such a spec carries _structure_only: true
and is refused by every write, since apply would empty what it omits. It is for
reviewing and diffing.
Patches¶
A changeset entry with a _base map is a patch: it holds only the paths
that changed, each fingerprinted with its value before the change. The runner
writes a path only where this site still has that value, so edits made here
since survive. An entry without _base is a full-state spec, with upsert as
above; a changeset without patches or files runs exactly as described there.
{
"entity_type": "node",
"bundle": "landing",
"uuid": "0a8ba38b-c932-40ca-bd7f-f28a87686ac8",
"_base": {
"en:title": "6f1ed002ab5595859014ebf0951522d9a9a2cbfcac3f8fbdcc1bb5d0b4bf61b9",
"en:paragraph/3f0c5b0e-7d8a-4b6e-9c1d-2e3f4a5b6c7d/field_heading": "c1c39b8e6a8a4b7f55b6c3f1a5ad0f8f2c4f1d2e3b4a5c6d7e8f9a0b1c2d3e4f",
"en:paragraph/9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a/field_cards": "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8"
},
"_base_revision": 41,
"title": "Opening times",
"paragraphs": {
"3f0c5b0e-7d8a-4b6e-9c1d-2e3f4a5b6c7d": { "field_heading": "By boat" },
"9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a": {
"field_cards": [
"3f0c5b0e-7d8a-4b6e-9c1d-2e3f4a5b6c7d",
{ "bundle": "card", "uuid": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "field_heading": "By bike" }
]
}
}
}
Paths¶
| Path | Value |
|---|---|
<langcode>:<field> |
A field of the entity. title and path are the spec's own keys: the label, and the custom alias (null when the alias is generated or absent). |
<langcode>:status |
The publishing state: the workflow state's id under content moderation, true or false otherwise. Read from the newest revision. |
<langcode>:paragraph/<uuid>/<field> |
A field of a paragraph anywhere in the entity's tree. |
A paragraph field's own path holds its paragraphs' uuids in order, not their contents, so reordering a list and editing a paragraph in it are separate changes. Untranslatable fields use the entity's default langcode.
The entity's default language has a path for every value. Each other language
has paths for what it translates: its label and alias, its translatable
fields, and its translation of each paragraph's, such as
el:paragraph/<uuid>/field_heading.
Values are spec values with references as uuids. A file is referenced by the
SHA-256 of its bytes, {"sha256": …} beside the item's other properties, so
the same image compares equal on every site. An empty field is null.
Keys of a patch¶
| Key | Meaning |
|---|---|
_base |
Each path the patch writes, mapped to the SHA-256 of its value before the change. An empty map says the entity is new. |
_base_revision |
Optional. The id of the revision the base was read from. |
_unit |
Optional. Entries with the same _unit are decided together: see Units. |
a field name, title, path |
The new value of that host path. null empties it. |
status |
Publishing, as a change of its own: a published state, the workflow state's id or true. A patch does not take content offline; an unpublish entry does. |
translations |
Another language's changed host values, keyed by langcode, as in a content spec. |
paragraphs |
Changed paragraph fields, keyed by paragraph uuid, each holding only its changed fields. A reserved key in every spec. |
A paragraph field appears only when its order or membership changed, as a
list: a uuid keeps or moves in an existing paragraph, an object is a new
paragraph written out whole, with its uuid and its translations. A
paragraph's changed translations sit in its paragraphs entry under
translations. children is not accepted in a patch; name the field.
Fingerprints¶
A fingerprint is the SHA-256, in lowercase hexadecimal, of the value's canonical JSON as RFC 8785 defines it: object members sorted by the UTF-16 code units of their names, no whitespace, strings escaping only what JSON requires. Two departures:
- A float is written as a JSON string holding PHP's shortest round-trip form,
such as
"1.5","1.0"or"1.0e+25". Field values can hold floats, and a fingerprint must not depend on number formatting. - An empty object and an empty list are both
[], as PHP has one empty array.
tests/fixtures/canonical-json.json holds values with their canonical text and
digests, made by a separate implementation.
Per path¶
Each path is compared with this site's newest revision of the entity, which is a pending draft when there is one; in several languages, each language's paths with that language's newest revision. A language this site lacks holds nothing, so each of its values compares as empty:
| This site has | The path |
|---|---|
| the base value | is applied |
| the new value | is skipped |
| anything else | conflicts |
A path the patch does not name is never written.
When _base_revision names this site's default revision of the same uuid, and
no draft is pending, nothing stored in a revision can have changed since the
base, so paths are applied without comparing. path is compared regardless:
an alias is its own entity and changes without a revision. A revision id that
belongs to another entity is ignored, since ids are per site.
Outcomes¶
| Entity | Outcome |
|---|---|
| No conflict, no draft pending | Its applied paths are written as a new revision, published when the changeset publishes. |
Unpublished here, and the patch has no status |
Its applied paths are written, and it stays unpublished, flagged: the publishing state is a path the changeset did not touch. |
| A conflict | A pending draft built on this site's current revision, with every staged path applied, flagged for review. |
| A draft already pending | The draft is built on, never discarded, and the result stays a draft, flagged. |
_base is empty, and the entity is not here |
Created, from every path of the entry, and flagged. |
_base is not empty, and the entity is not here |
Skipped, and flagged. |
| Every path skipped | Nothing is written. |
A patch writes one revision per language it changes, the default language's first, as content specs do; only the paths it names are written in each. A Greek edit made here is therefore no conflict for a staged English change, and survives it.
A draft cannot hold an alias: core saves an alias the moment its entity is
saved, whatever the revision. A path in a patch that ends as a pending draft
waits in the draft's review record instead, and is set by the save that
publishes the draft, whoever makes it: the Publish action,
content-spec:publish, or core's moderation form. A redirect keeps the old
alias's links working. content-spec:read reports the waiting alias as the
draft's path.
Conflicts never fail a run. The changeset is recorded with them, and the command exits 0.
Review¶
Each draft a patch leaves is recorded for review as content-spec:pending
lists, with:
| Key | Value |
|---|---|
changeset |
The changeset's id. |
conflicts |
Each conflicting path, with the field's label, the value here before the run, and the value staged. |
flags |
As in the run's output. |
base_revision, base_revision_path |
The base revision and where to see it in this site's history, when this site has that revision. |
held_alias |
{value}: the alias the draft waits for, null handing it back to Pathauto. Kept when a later draft is built on this one. |
drush content-spec:pending --conflicts lists only those drafts. The notice on
the page names the conflicting fields and links to the base revision. The event
content_deployment.changeset_conflict
(Drupal\content_deployment\Event\ChangesetConflictEvent) fires once per
entity with a conflict, after its draft is saved, carrying the draft, the
changeset id and the conflicts.
Units¶
A paragraph moving from one entity to another changes both: one list loses it,
the other gains it. Entries with the same _unit are decided together: if any
of them is held as a draft, all of them are, so the paragraph never leaves a
published page for a draft of another.
Unpublishing with a base¶
An unpublish entry with a _base maps the state path, <langcode>:status,
to the fingerprint of the entity's state before the change: the workflow state
under content moderation, the published flag otherwise.
| This site has | The entry |
|---|---|
| the entity offline | is skipped, as already offline |
| the base state, and no draft pending | runs |
| anything else | is skipped as skipped: conflict, and flagged: someone here is working on it |
Files¶
A changeset carries files in its directory, beside changesets/, addressed by
SHA-256:
"files": {
"ab12…ef": { "path": "files/ab/ab12…ef.jpg", "size": 48213, "uri": "public://2026-10/harbour.jpg" }
}
| Key | Meaning |
|---|---|
path |
Relative to the directory holding changesets/. |
size |
In bytes. |
uri |
Optional. Where the file lived where it came from; a stored file goes there, renamed if the name is taken, when this site has that scheme, and under public://content-deployment/ otherwise. |
Before anything is written, every file is checked: present, of that size, with
those bytes. A Git LFS pointer, from a checkout made without git lfs pull, is
named as such. Any problem fails the run, with nothing recorded.
Bytes this site already has are found through File Hash, and that file is
used. The rest are stored as new permanent files, owned by the module's
account. Every {"sha256": …} reference is then written as that file. A
changeset carrying files needs File Hash with SHA-256 enabled; one without
files does not.
Exporting from a staging workspace¶
content_deployment_export, on dev only, turns a staging workspace into the
next changeset.
drush content-deployment:export stage --dry-run
drush content-deployment:export stage
drush content-deployment:export stage --label=spring_campaign
The same export is the Export changeset operation in the workspace list, for
accounts with export content deployment changesets. Its confirm form shows
what --dry-run prints: each entity with its action and paths, each file with
its size, what is left out and why, and what stops the export. Where the web
server cannot write the directory, the form offers the changeset and its files
as a .tar.gz laid out as the directory is.
The export:
- reads the workspace's tracked entities from
workspaces.tracker, folding a paragraph into the entity at the top of its tree and an alias into the entity it names; - reads each entity inside the workspace for the staged values and outside it
for the base, and writes every changed path into a patch, with
_baseand_base_revision; an entity the workspace created is written out whole, with an empty_base; - joins entities that a paragraph moved between into a
_unit; - writes publishing as a change: an entity published in the workspace and not
in Live gets a
statusin its patch, with Live's state as its base; - writes each language's changed paths: another language's under
translations, a paragraph's translations in itsparagraphsentry, and new paragraphs with their translations; - writes an entity unpublished in the workspace, and published in Live, as an
unpublishentry with a base; one unpublished in the workspace otherwise is left out, since an export publishes what it carries; - copies the bytes of every file a changed path references to
files/<first two hex>/<sha256>.<ext>, once per SHA-256, and refuses a file over the size limit; - writes
changesets/NNNN_<label>.json, numbered after the highest number there, with an explicit id<workspace>:<NNNN>:<random>,"publish": true, and new entities first; - records the changeset as run on dev, where its content already is: running it into Live while the workspace is open would create the workspace's own entities a second time.
The workspace stays open. Every value exported is remembered per workspace in
the key-value collection content_deployment_export.exported, and the next
export holds only paths changed since, with the remembered fingerprints as
_base.
Accounts and redirects cannot be saved inside a workspace, so they join a
changeset as hand-written full-state entries in entities.
| Setting | Default | Meaning |
|---|---|---|
$settings['content_deployment_max_file_size'] |
10485760 (10 MiB) |
The largest file an export carries, in bytes. |
Running on deploy¶
With it, drush deploy runs the pending changesets after deploy:hook, as
content-deployment:run would. Without it, drush deploy prints how many are
pending, and the status report warns while any are.