CI & Automation
Automating Environment Sync buys you two things: every pull request shows what merging it would change on production, and merging applies it without anyone running a command. This page builds that pipeline and covers the rules unattended runs follow.
The non-interactive contract
The CLI treats any environment with the CI variable set as non-interactive (locally, --no-interactive simulates the same behavior). Where the interactive CLI would ask a question, a non-interactive run refuses instead:
- An ambiguous record match (two target records that could both be the committed one) fails the command rather than picking a candidate. Resolve it once with an interactive push; the answer lands in the committed identity map and CI runs cleanly after that.
- Deletions happen only with
--dangerously-allow-delete. Amirrorpush without it refuses before changing anything on the target. --yesconfirms an ordinary, non-destructive apply. It never authorizes a deletion.
Commands exit 0 on success and 1 on any refusal or failure; there are no other exit codes. Anything finer-grained (which kind of failure, how many changes) comes from the JSON report, not the exit code.
Credentials
Pass tokens through environment variables named DIRECTUS_<PROFILE>_TOKEN, the profile name uppercased. Profile names use letters, numbers, and underscores, so the mapping is mechanical: profile production reads DIRECTUS_PRODUCTION_TOKEN, profile staging_eu reads DIRECTUS_STAGING_EU_TOKEN.
The credential store saved on a developer machine is never read when CI is set; tokens come from the environment only.
JSON reports
Add --json and stdout carries exactly one machine-readable report per command. Warnings (stripped secret fields, version drift, flow headers exported verbatim) still go to stderr, so your logs keep them while stdout stays parseable. A failure puts an error report on stdout instead, with a stable code naming the failure class.
The fields automation usually keys on:
changes(diff):truewhen the push would do anything, including unresolved records.unresolved(diff): the count of ambiguous record matches. A non-interactive push refuses while this is non-zero, so an unresolved diff is a real difference for your pipeline to surface, not noise.applied(push):truewhen the push changed the target.
d6s sync diff exits 0 whether or not differences exist; it fails only when it cannot produce an answer. Gate pipeline behavior on the report's changes, not the exit code. The reference documents every report field.
A GitHub Actions pipeline
One workflow, two jobs: pull requests get the production diff as a comment, and merges to main apply the committed files. Store the token as an Actions secret.
name: environment-sync
on:
pull_request:
push:
branches: [main]
jobs:
diff:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @directus/cli
- name: Diff against production
run: d6s sync diff --to production --json > diff-report.json
env:
DIRECTUS_PRODUCTION_TOKEN: ${{ secrets.DIRECTUS_PRODUCTION_TOKEN }}
- name: Comment the result on the PR
uses: actions/github-script@v7
with:
script: |
const report = require('./diff-report.json');
const body = report.changes
? `**Environment Sync**: merging changes production. ${report.added} added, ` +
`${report.modified} modified, ${report.deleted} deleted schema items; ` +
`${report.unresolved} unresolved records.`
: '**Environment Sync**: production already matches this branch.';
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @directus/cli
- name: Push to production
run: d6s sync push --to production --yes --json > push-report.json
env:
DIRECTUS_PRODUCTION_TOKEN: ${{ secrets.DIRECTUS_PRODUCTION_TOKEN }}
- name: Commit the updated identity map
run: |
if [ -n "$(git status --porcelain -- 'directus/*/id_map.json')" ]; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add 'directus/*/id_map.json'
git commit -m "Update sync identity map"
git push
fi
Two things to know before enabling the push job:
- Run the first push interactively, locally. The first push into a target tends to raise the identity questions described in How It Works, and CI refuses them. Answer them once from a terminal, commit
id_map.json, and CI is clean from then on. - The identity map commit-back step matters. A push that creates records adds entries to
id_map.json. If CI doesn't commit them, the next push re-matches those records from scratch, and records without an identifying field (panels) can duplicate.
A scheduled pull is the same pattern in reverse: run d6s sync pull --from staging --json on a cron trigger and commit the result. A clean working tree means nothing changed on the instance; a diff is drift, arriving as a reviewable commit or PR instead of a surprise.
Mirror pushes in automation
A mirror push deletes, so it additionally requires --dangerously-allow-delete:
d6s sync push --to staging --mode mirror --yes --dangerously-allow-delete
Reserve this for pipelines that rebuild disposable environments, and keep production pushes on the default merge unless a human reviewed the deletions in the diff. The flag name is deliberate.
Get once-a-month release notes & real‑world code tips...no fluff. 🐰
Common Workflows
End-to-end walkthroughs of the most common Environment Sync workflows, from promoting changes to production to adopting sync on an existing project, rolling back, and recovering a drifted environment.
Secrets & Limitations
How Environment Sync keeps secret values out of the files you commit, the one exception to review before publishing a repository, and what the tool deliberately does not do.