CI & Automation

Post the production diff on every pull request and push on merge, with tokens from environment variables, machine-readable JSON reports, and explicit flags in place of prompts.

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. A mirror push without it refuses before changing anything on the target.
  • --yes confirms 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): true when 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): true when 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. 🐰