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 (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; 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 in place of the success report, shaped {"error": {"code": …}} with a stable code naming the failure class. Redirecting stdout to a file therefore captures the error instead of showing it; pipe through tee so the failure is visible in your logs too.

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 sent an apply or import to the target.
  • data is null when the project has no Configuration files at all, so reach through it (report.data?.…) rather than assuming an object.
  • data.unchanged (diff) counts matched records whose values already agree. The server reports every matched record under resultsByCollection[…].existing whether or not it differs, so an honest "updated" count is existing minus unchanged.

d6s sync diff exits 0 whether or not differences exist; it fails only when it cannot produce an answer. Distinguish those two: check for error first, then gate pipeline behavior on changes rather than 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_URL: ${{ vars.DIRECTUS_PRODUCTION_URL }}
        run: |
          actual=$(jq -r '.profiles.production.url // "<missing>"' directus.config.json)
          if [ -z "$EXPECTED_URL" ] || [ "$actual" != "$EXPECTED_URL" ]; then
            echo "Unexpected production profile URL: $actual"
            exit 1
          fi
      - name: Diff against production
        shell: bash
        run: d6s sync diff --to production --json | tee 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 = JSON.parse(require('fs').readFileSync('diff-report.json', 'utf8'));
            const sum = (key) =>
              Object.values(report.data?.resultsByCollection ?? {}).reduce((total, result) => total + result[key].length, 0);
            const updated = sum('existing') - (report.data?.unchanged ?? 0);
            const schema = report.schemaSkipped
              ? 'Schema: skipped.'
              : `Schema: ${report.added} added, ${report.modified} modified, ${report.deleted} deleted.`;
            const body = report.changes
              ? `**Environment Sync**: merging this PR changes production. ${schema} ` +
                `Configuration: ${sum('new')} created, ${updated} updated, ${sum('deleted')} deleted; ` +
                `${report.data?.reconciliation?.ambiguous ?? 0} 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: Verify the production profile URL
        env:
          EXPECTED_URL: ${{ vars.DIRECTUS_PRODUCTION_URL }}
        run: |
          actual=$(jq -r '.profiles.production.url // "<missing>"' directus.config.json)
          if [ -z "$EXPECTED_URL" ] || [ "$actual" != "$EXPECTED_URL" ]; then
            echo "Unexpected production profile URL: $actual"
            exit 1
          fi
      - name: Push to production
        shell: bash
        run: d6s sync push --to production --yes --json | tee push-report.json
        env:
          DIRECTUS_PRODUCTION_TOKEN: ${{ secrets.DIRECTUS_PRODUCTION_TOKEN }}
      - name: Commit the updated ID map
        if: ${{ !cancelled() }}
        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. It runs on !cancelled() rather than only on success, because a push that imports records and then fails still wrote real mappings worth keeping.
  • The bot needs somewhere to push. Branch protection that requires pull requests blocks github-actions[bot] from committing the ID map; give it an exemption or have the step open a pull request instead. Commits made with GITHUB_TOKEN never trigger another workflow run, so this cannot loop.

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. 🐰