03 Sep 2026 04:53 PM - edited 03 Sep 2026 06:03 PM
How we keep a Git repo as the source of truth for dozens of Dynatrace Automation Workflows — automatically
Dynatrace Automation Workflows are easy to edit in the UI — which is exactly why they drift out of sync with source control. Someone tweaks a condition, adds a task, or fixes a typo directly in the browser, and the change never makes it back into Git. A few months later, nobody can tell you what's actually deployed versus what's in the repo, and a "rollback" from Git silently reverts someone's fix.
We wanted a Git repo to be the actual source of truth for our ~35 Dynatrace workflows, with automatic detection whenever live and local state diverge — in either direction.
The result is a small GitHub Actions pipeline built around dtctl (the Dynatrace CLI) and a Python script that reconciles drift automatically, plus a companion script for manual, read-only checks.
[ Dynatrace (live workflows) ] --dtctl get workflows--> [ GitHub Actions (scheduled run) ]
|
v
compare live vs. local YAML
|
+---------------------+---------------------+
| |
v v
no drift -> exit clean drift found -> write files,
open branch + PR for reviewTwo scripts, two purposes:
Nothing is ever auto-merged. Every drift correction lands as a pull request that a human reviews before it hits master.
dtctl can dump every workflow in an environment as YAML in one call:
dtctl get workflows -o yaml
This returns a YAML stream of every workflow object, including its id, title, trigger, and tasks. That id field is the join key between "live" and "local" — we key everything off it rather than filename or title, since titles change but IDs don't.
The comparison logic — whether in the CI script or the manual one — is deliberately simple and read-only:
docs = list(yaml.safe_load_all(result.stdout))
live_by_id = {wf["id"]: wf for wf in docs if wf.get("id")}
local_files = {}
for fname in sorted(os.listdir(WF_DIR)):
...
if "id" in data:
local_files[data["id"]] = (fname, data)
for wf_id, (fname, local_data) in local_files.items():
if wf_id not in live_by_id:
only_local.append(fname) # deleted in DT, still in git
continue
if yaml.dump(local_data, sort_keys=True) != yaml.dump(live_by_id[wf_id], sort_keys=True):
changed.append((fname, wf_id, diff)) # driftedIt normalizes both sides to canonical YAML (sort_keys=True) before comparing, so key ordering never produces a false positive. Three buckets fall out of this:
One early lesson: not every field is meaningful in version control. Dynatrace stamps a nextExecution timestamp onto schedule-triggered workflows that it auto-advances on every run. Left in the diff, it made every scheduled workflow show as "changed" on every sync. We strip it (and anything else purely runtime) before comparing or writing:
RUNTIME_FIELDS = {"nextExecution"}
def strip_runtime(data):
if isinstance(data, dict):
return {k: strip_runtime(v) for k, v in data.items() if k not in RUNTIME_FIELDS}
if isinstance(data, list):
return [strip_runtime(i) for i in data]
return dataIf you hit persistent noisy diffs in your own drift detection, look for auto-generated/runtime fields like this before assuming something is actually wrong.
sync_workflows.py — the script that actually runs in CI — does the same comparison, then acts on it: it writes the live Dynatrace state to the local YAML files, git adds only the files that changed, and commits with a message summarizing what changed and what's new — never touching files that didn't drift.
The GitHub Actions job wraps this in a disposable branch so the change is always proposed, not applied directly:
- name: Create sync branch
id: branch
run: |
BRANCH="sync/dynatrace-workflows-$(date +%Y-%m-%d)"
git checkout -b "$BRANCH"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Run workflow sync
run: python3 scripts/sync_workflows.py
- name: Push branch and open PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if git diff --quiet HEAD origin/master 2>/dev/null; then
echo "Nothing to sync - skipping PR."
else
git push -u origin "${{ steps.branch.outputs.name }}"
gh pr create --base master --head "${{ steps.branch.outputs.name }}" \
--title "sync: update Dynatrace workflows $(date +%Y-%m-%d)" \
--body "Automated daily sync of Dynatrace workflows via sync_workflows.py."
fiIf nothing drifted, the job exits cleanly with no PR — no noise in the repo on a quiet day.
Putting it together, the complete GitHub Actions workflow runs on a weekday schedule and can also be triggered manually:
name: Sync Dynatrace Workflows
on:
schedule:
- cron: '0 13 * * 1-5' # 9 AM ET (UTC-4), Mon-Fri
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: pip install pyyaml
- name: Install dtctl
run: |
curl -fsSL https://github.com/dynatrace-oss/dtctl/releases/download/v0.28.1/dtctl_0.28.1_linux_amd64.tar.gz | tar -xz -C /usr/local/bin
chmod +x /usr/local/bin/dtctl
- name: Configure dtctl
env:
DT_PLATFORM_TOKEN: ${{ secrets.DT_PLATFORM_TOKEN }}
run: |
mkdir -p ~/.config/dtctl
printf 'apiVersion: v1\nkind: Config\ncurrent-context: prod\ncontexts:\n- name: prod\n context:\n environment: YOUR_DT_ENVIRONMENT_URL\n token-ref: ci-token\n safety-level: readwrite-all\ntokens:\n- name: ci-token\n token: "%s"\npreferences:\n output: yaml\n' "$DT_PLATFORM_TOKEN" > ~/.config/dtctl/config
- name: Create sync branch
id: branch
run: |
BRANCH="sync/dynatrace-workflows-$(date +%Y-%m-%d)"
git checkout -b "$BRANCH"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Run workflow sync
env:
GIT_ROOT: ${{ github.workspace }}
WF_DIR: ${{ github.workspace }}/dynatrace-workflows
DTCTL_PATH: /usr/local/bin/dtctl
run: python3 scripts/sync_workflows.py
- name: Push branch and open PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if git diff --quiet HEAD origin/master 2>/dev/null; then
echo "Nothing to sync - skipping PR."
else
git push -u origin "${{ steps.branch.outputs.name }}"
gh pr create \
--base master \
--head "${{ steps.branch.outputs.name }}" \
--title "sync: update Dynatrace workflows $(date +%Y-%m-%d)" \
--body "Automated daily sync of Dynatrace workflows via sync_workflows.py."
fiWe considered having the pipeline just overwrite the repo and commit straight to master. We didn't, for two reasons:
If you're managing more than a handful of Dynatrace Automation Workflows, this pattern generalizes well beyond workflows — the same dtctl get ... -o yaml + diff + PR shape works for IAM policies, notification settings, or any other Dynatrace config object that has a CLI export path.
Companion post: Managing Dynatrace Automation Workflows as Code with dtctl and GitHub Copilot covers authoring and deploying individual workflows with Copilot — this post covers keeping the whole fleet in sync afterward.
Featured Posts