Most of my GitOps writing and talking so far has been about Jamf scripts: Shikomi, pre-commit hooks, and the CI gates that catch a bad script before it reaches a Mac. That is one half of the idea. The other half is treating the configuration of the MDM itself as code, so that a setting change gets the same review and rollback story as a script change.

To get a feel for that, I stood up a small Fleet GitOps repo and pointed it at a test instance. Everything below comes from that repo. It is a lab, not a production fleet, and it went from empty to working in a day. That speed is a feature of how Fleet structures its files.


The Shape of the Repo

The repo starts from the scaffold that fleetctl new generates, and the layout is organized by platform first, then by kind of thing:

default.yml                  # org-wide settings, global controls, labels
fleets/
  workstations.yml           # one manifest per fleet
  personal-mobile-devices.yml
labels/
  *.yml                      # one file per label
platforms/
  macos/
    configuration-profiles/
    declaration-profiles/
    enrollment-profiles/
    policies/
    reports/
    scripts/
    software/
  windows/ ios/ ipados/ linux/ android/ all/
.github/
  fleet-gitops/              # composite action + gitops.sh
  workflows/workflow.yml

The split that matters is between manifests and content. default.yml and the files in fleets/ are manifests: they say what exists and who it applies to. Everything under platforms/ is content: the actual profile, script, or policy. Manifests pull content in with path globs, so adding a policy is dropping a file in the right folder rather than editing a list:

policies:
  - paths: ../platforms/macos/policies/*.yml
  - paths: ../platforms/windows/policies/*.yml
  - paths: ../platforms/linux/policies/*.yml

scripts:
  - paths: ../platforms/macos/scripts/*.sh

Empty folders are kept alive with .gitkeep files, which sounds trivial but matters: the globs always resolve, and the layout documents where a new thing is supposed to go.


Fleets, Not One Big Bucket

Each file in fleets/ is a separate population with its own controls. I have two: company-owned workstations, and personal mobile devices. They differ in exactly the ways you would want. The workstation manifest enables disk encryption with key escrow, sets a macOS minimum version with a deadline, and lists installable software. The personal-device manifest cherry-picks a passcode profile and nothing else, because the policy for a device someone owns should be much lighter than for one you issued.

Having the two sets of rules sit in two small files makes that difference reviewable at a glance. In a GUI, the same difference lives in two different screens that nobody compares.


Labels Are the Scoping Layer

Labels do the targeting work, and the repo uses all three membership types Fleet offers:

# Dynamic: an osquery query decides membership
- name: Apple Silicon macOS hosts
  query: SELECT 1 FROM os_version WHERE arch LIKE 'ARM%';
  label_membership_type: dynamic
  platform: darwin

# Host vitals: membership comes from the identity provider
- name: Department - Engineering
  label_membership_type: host_vitals
  criteria:
    vital: end_user_idp_department
    value: Engineering

# Manual: a hand-maintained list of hosts
- name: C-Suite
  label_membership_type: manual

Profiles and software then reference labels by name, with labels_include_any, labels_include_all, or labels_exclude_any. A profile scoped to one department is a three-line block in the fleet manifest. The personal-device passcode profile uses labels_exclude_any so a specific group is skipped. Reading the manifest tells you who gets what, which is the property I care about most in any MDM.


Policies That Fix Things

A Fleet policy is an osquery query that returns a row when a host is compliant. Mine are one file each, with human text for the person whose Mac is failing:

- name: macOS - All available software updates installed
  query: SELECT 1 FROM software_update WHERE software_update_required = 0;
  critical: false
  description: This Mac may have outdated system software...
  resolution: |
    Please run all available updates from Software Update...
  platform: darwin

A policy can also carry a run_script, which turns it from a report into a remediation: when the host fails, Fleet runs the referenced script from the same repo. That is the part that connects this back to the Jamf scripting work. The script is just a file in platforms/macos/scripts/, so it is reviewed like any other script.


The CI: Dry Run on Every PR

The pipeline is a small GitHub Actions workflow plus a composite action and a ~50-line gitops.sh. The behavior is simple:

  • Pull request: run fleetctl gitops with --dry-run only. Fleet validates the whole configuration against the server and reports what would change, and nothing is applied.
  • Push to main: dry run first, then the real run.
  • Nightly schedule: apply again, which corrects drift if someone changed something in the UI.

The switch is one expression in the workflow:

dry-run-only: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}

Two details in the action I liked. First, it asks the Fleet server for its version and installs the matching fleetctl, falling back to latest for snapshot builds, so the CLI and server do not drift apart. Second, a concurrency group with cancel-in-progress: false stops two applies from overlapping without killing one that is mid-run.

The script also sanity-checks the repo before talking to the server: it requires org_settings in the global file and fails if two fleet manifests share a name.

One flag deserves respect: the action defaults delete-other-fleets to true. Anything on the server that is not defined in a file gets removed. That is the correct GitOps behavior, and also the setting that turns a typo in a fleet name into a bad afternoon. The dry run is what makes that tolerable.


Where This Connects to Shikomi

The idea from my talks is that mistakes should be caught at two gates: locally before commit, and in CI before merge. The dry run on every pull request is the second gate, and it works well. The first gate is where I want to extend the lab next:

  1. Branch protection on main, so the dry-run check is required rather than advisory.
  2. Shellcheck and secret scanning on platforms/*/scripts/, the same pre-commit setup Shikomi scaffolds for Jamf scripts. Remediation scripts run as root on endpoints, which makes them exactly the kind of file that benefits from strict mode and linting.
  3. Secrets handling through CI. Fleet’s configuration supports $VARIABLE substitution from CI secrets (the server URL here is injected that way), and anything sensitive should follow that pattern rather than sit in a manifest.

What I Took Away

  • Path globs are the best idea in the layout. Adding content without touching a manifest keeps diffs small and review focused.
  • Manifests versus content is worth copying anywhere. I am now thinking about how to apply the same split to the Jamf side.
  • Dry-run-on-PR is the whole value proposition. Without it this is just YAML in a repo; with it, a config change gets a preview, a reviewer, and a revert button.
  • The gates are the point. Dry runs, required checks, and local linting are what turn a repo of YAML into a change process.

Next I want to run the same exercise the other direction: take a real set of Jamf configuration profiles and see how much of that estate reads cleanly as files in a layout like this one.