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 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. 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 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): true when 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.dependent counts 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): 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 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.json before 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. 🐰