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 a non-empty CI variable as non-interactive, except for the literal value false (locally, --no-interactive forces 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 correspond to the source record) fails the command rather than picking a candidate. Resolve it with an interactive push and commit the ID map; later CI pushes between the same source and target URLs reuse the answer.
- 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 non-empty, except when its value is false; tokens come from the environment only.
JSON reports
Add --json and stdout carries exactly one machine-readable report per command. Warnings (stripped secret fields, compatibility checks bypassed with --allow-drift, flow headers written 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 when Configuration has ambiguous target matches.data.reconciliation.ambiguous(diff): the number of Configuration records that need an identity choice.data.reconciliation.dependentcounts records waiting on those choices. A non-interactive push refuses this state, so it 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 reviewed sync files. Store the token as the DIRECTUS_PRODUCTION_TOKEN Actions secret and the expected instance URL as the DIRECTUS_PRODUCTION_URL Actions variable.
name: environment-sync
on:
pull_request:
push:
branches: [main]
jobs:
diff:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
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@12
- name: Verify the production profile URL
env:
EXPECTED_DIRECTUS_URL: ${{ vars.DIRECTUS_PRODUCTION_URL }}
run: |
node - <<'NODE'
const fs = require('fs');
const config = JSON.parse(fs.readFileSync('directus.config.json', 'utf8'));
const actual = config.profiles?.production?.url;
if (!process.env.EXPECTED_DIRECTUS_URL || actual !== process.env.EXPECTED_DIRECTUS_URL) {
throw new Error(`Unexpected production profile URL: ${actual ?? '<missing>'}`);
}
NODE
- 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 fs = require('fs');
const report = JSON.parse(fs.readFileSync('diff-report.json', 'utf8'));
const ambiguous = report.data.reconciliation?.ambiguous ?? 0;
const configuration = Object.values(report.data.resultsByCollection ?? {}).reduce(
(total, result) => ({
created: total.created + result.new.length,
updated: total.updated + result.existing.length,
deleted: total.deleted + result.deleted.length,
}),
{ created: 0, updated: 0, deleted: 0 },
);
const body = report.changes
? `**Environment Sync**: merging changes production. Schema: ${report.added} added, ` +
`${report.modified} modified, ${report.deleted} deleted. Configuration: ` +
`${configuration.created} created, ${configuration.updated} updated, ` +
`${configuration.deleted} deleted; ${ambiguous} ambiguous matches.`
: '**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
concurrency:
group: environment-sync-production
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @directus/cli@12
- 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 ID 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 ID 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 from a terminal and commit
id_map.jsonbefore enabling the push job. - The ID 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.
The URL check must run before any step receives the production token. It prevents a pull request from changing the production profile to another host and sending the token there. The sample skips pull requests from forks because GitHub does not expose repository secrets to them; run local or tokenless checks for those contributions instead.
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.