[B b65.ch](/)

![](https://www.b65.ch/images/ansible-mac-maintenance-hero.jpg)

On this page

# One Ansible playbook for weekly Mac maintenance

* * *

Keeping a Mac current means touching several things that don't know about each other: Homebrew, the App Store, macOS itself, a few dev tools and the caches that pile up in between. I used to do this by hand, in whatever order I remembered. Now one playbook does it, and once a week it runs without me.

It is a single file that runs against localhost, so there is no inventory and no SSH. Most of it is plain `brew upgrade` and friends. This post is about the places where the obvious version doesn't work, because those are the decisions that matter.

The snippets below follow the order of the file. Paste them together from top to bottom and you have the complete playbook.

## Two modes, one file

A manual run does everything, including cask upgrades and the Mole cleanup. The scheduled run passes `-e unattended=true` and skips both. The header comment shows the three ways to call it, and the vars block holds the switches.

`--- # Mac-Wartung: Updates + Cleanups für Homebrew, App Store, Dev-Tools und Caches (Mole). # Läuft wöchentlich unattended per LaunchAgent (-e unattended=true, ohne Mole) oder manuell mit vollem Umfang. # # Aufruf: # ansible-playbook playbook-mac-maintenance.yml # manuell (inkl. Casks, mo optimize) # ansible-playbook playbook-mac-maintenance.yml -e mole_dry_run=true # Mole nur als Vorschau # ansible-playbook playbook-mac-maintenance.yml --tags schedule # LaunchAgent (Sonntag 10:00) installieren # # Tags: brew, mas, devtools, cleanup, report, schedule # Log des LaunchAgents: ~/Library/Logs/mac-maintenance.log - name: Mac warten hosts: localhost connection: local gather_facts: false vars: home: "{{ lookup('env', 'HOME') }}" unattended: false # true = ohne Cask-Upgrades, ohne Mole (mo clean/optimize/purge) mole_dry_run: false agent_label: ch.b65.mac-maintenance agent_plist: "{{ home }}/Library/LaunchAgents/{{ agent_label }}.plist" dotfiles_dir: "{{ home }}/.dotfiles" brewfile: "{{ dotfiles_dir }}/Brewfile" # launchd startet mit Minimal-PATH environment: PATH: "/opt/homebrew/bin:/opt/homebrew/sbin:{{ home }}/.bun/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" HOMEBREW_NO_ENV_HINTS: "1" tasks: - name: Fehlerliste initialisieren set_fact: maint_failures: [] tags: always`

launchd starts jobs with a minimal PATH, so `brew`, `mas` and `bun` aren't found. The `environment` block sets the PATH explicitly, with `/opt/homebrew/bin` first. This is the kind of thing that works in a terminal and fails the first time the schedule fires.

The last task initialises a `maint_failures` list. You'll see what it is for in a moment.

## Measuring what the run did

Free disk space is read with `df -k` before the work starts and again at the end. The difference goes into the report. It is a net number, because upgrades install files too.

`# ────────────────────────────────────────────── # Freier Platz vorher # ────────────────────────────────────────────── - name: Freien Platz messen (vorher, KB) shell: df -k / | awk 'NR==2{print }' register: free_before changed_when: false tags: always`

## Homebrew

Each area of the playbook is a `block` with a `rescue` that appends its name to `maint_failures`. If `mas upgrade` fails, the dev tools still get updated. A `fail` task at the very end stops the play with a non-zero exit code if the list isn't empty, and the names of the broken areas end up in the log.

`# ────────────────────────────────────────────── # Homebrew # ────────────────────────────────────────────── - name: Homebrew aktualisieren tags: brew block: - name: brew update command: brew update changed_when: false`

### Ansible can't upgrade the things it runs on

Ansible depends on Python, cryptography and a few other formulae. If a play upgrades those while it is running, the interpreter changes underneath the play. The fix is to filter the ansible stack out of the list of outdated formulae. `brew deps` lists everything ansible depends on, `sed` turns each name into a `-e` pattern, and `grep -vxF` drops exact matches. The `|| true` is needed because `grep -v` exits with 1 when nothing is left over, and that is the good outcome here.

`# ansible + Abhängigkeiten (python, cryptography, ...) bleiben aussen vor, sonst zerlegt sich der laufende Lauf; # Upgrade dafür manuell nach dem Lauf (Hinweis im Report) - name: Veraltete Formulae ermitteln (ohne ansible-Stack) shell: brew outdated --formula --quiet | grep -vxF -e ansible $(brew deps --formula ansible | sed 's/^/-e /') || true register: outdated_formulae changed_when: false - name: brew upgrade (Formulae) command: "brew upgrade --formula {{ outdated_formulae.stdout_lines | join(' ') }}" register: brew_upgrade changed_when: "'Upgrading' in brew_upgrade.stdout" when: outdated_formulae.stdout_lines | length > 0`

Whatever is still outdated after the run is the ansible stack. A later task collects it, and it shows up in the report as a command to run by hand.

### Casks replace apps that may be open

`brew upgrade` without `--formula` also upgrades casks, which means it swaps application bundles on disk. That is fine while I'm sitting at the machine and a bad idea at 10:00 on a Sunday when something is open. So the scheduled run upgrades formulae only and lists outdated casks. I run the cask upgrade myself.

``# `brew upgrade` ohne --formula würde auch Casks tauschen (laufende Apps) -> Casks nur manuell - name: brew upgrade --cask (nur manuell) command: brew upgrade --cask register: brew_upgrade_cask changed_when: "'Upgrading' in brew_upgrade_cask.stdout" when: not unattended``

### The rest of the block

`changed_when` is set on every command task. Read-only commands get `false`, upgrades check the output for `Upgrading` or `Removing`, so the "changed" count in the recap means something. `brew cleanup --prune=30` keeps old downloads and versions for 30 days instead of deleting them at once.

The last two tasks only look. `brew doctor` is allowed to fail without failing the play. `brew bundle cleanup` runs without `--force`, so it prints what is installed but missing from the Brewfile and removes nothing.

`- name: Veraltete Casks ermitteln command: brew outdated --cask register: outdated_casks changed_when: false - name: brew autoremove command: brew autoremove register: brew_autoremove changed_when: "'Uninstalling' in brew_autoremove.stdout" - name: brew cleanup (Downloads/Versionen älter als 30 Tage) command: brew cleanup --prune=30 register: brew_cleanup changed_when: "'Removing' in brew_cleanup.stdout" - name: Übrige veraltete Formulae (ansible-Stack) command: brew outdated --formula --quiet register: stack_outdated changed_when: false - name: brew doctor command: brew doctor register: brew_doctor changed_when: false failed_when: false # Ohne --force: listet nur, was nicht im Brewfile steht - name: Brewfile-Drift prüfen (nur anzeigen) command: "brew bundle cleanup --file={{ brewfile }}" register: brew_drift changed_when: false failed_when: false rescue: - name: Fehler merken (brew) set_fact: maint_failures: "{{ maint_failures + ['brew'] }}"`

## App Store and macOS

`mas upgrade` installs App Store updates. macOS updates are different: installing them needs sudo and often a reboot, so the playbook only lists them with `softwareupdate -l` and leaves the decision to me.

`# ────────────────────────────────────────────── # App Store + macOS # ────────────────────────────────────────────── - name: App Store und macOS prüfen tags: mas block: - name: mas upgrade command: mas upgrade changed_when: false # Installation braucht sudo/Reboot -> nur auflisten - name: macOS-Updates auflisten command: softwareupdate -l register: softwareupdate changed_when: false rescue: - name: Fehler merken (mas) set_fact: maint_failures: "{{ maint_failures + ['mas'] }}"`

## Dev tools

`nvm install --lts` installs the current LTS release and leaves the default alias alone, so a weekly run never changes the Node version my shell uses. Oh My Zsh and Powerlevel10k are plain git checkouts, updated with `--ff-only` so a local change makes the pull fail instead of creating a merge.

`# ────────────────────────────────────────────── # Dev-Tools # ────────────────────────────────────────────── - name: Dev-Tools aktualisieren tags: devtools block: - name: Bun upgrade command: bun upgrade changed_when: false - name: Node LTS installieren (Default-Alias bleibt unverändert) shell: source "$HOME/.nvm/nvm.sh" && nvm install --lts --no-progress args: executable: /bin/bash register: nvm_lts changed_when: "'already installed' not in nvm_lts.stdout" - name: Oh My Zsh und Powerlevel10k aktualisieren command: "git -C {{ item }} pull --ff-only" loop: - "{{ home }}/.oh-my-zsh" - "{{ home }}/.oh-my-zsh/custom/themes/powerlevel10k" register: git_pull changed_when: "'Already up to date' not in git_pull.stdout" - name: pipx upgrade-all command: pipx upgrade-all register: pipx_upgrade changed_when: "'upgraded package' in pipx_upgrade.stdout" rescue: - name: Fehler merken (devtools) set_fact: maint_failures: "{{ maint_failures + ['devtools'] }}"`

## Mole and the permission prompts

I use [Mole](https://github.com/tw93/Mole) for the heavy cleanups (`mo clean`, `mo optimize`). Mole scans Desktop, Documents and Downloads. Started from launchd, macOS asks for access to those folders on behalf of `python`, and nobody is there to click the dialog. From Terminal the permissions are already granted. So the whole block has `when: not unattended`.

`mo purge` and `mo installer` delete project artifacts and installer files. The playbook only runs them with `--dry-run` and prints the last lines of the output. Whether to delete is my call.

`# ────────────────────────────────────────────── # Cleanups (Mole) # ────────────────────────────────────────────── # Mole scannt Desktop/Dokumente/Downloads -> unter launchd gäbe es TCC-Popups für python -> nur manuell (Terminal hat die Rechte) - name: Aufräumen mit Mole tags: cleanup when: not unattended block: - name: mo clean shell: "mo clean {{ '--dry-run' if mole_dry_run else '' }} </dev/null" changed_when: not mole_dry_run # Systemnahe Optimierungen -> nur manuell - name: mo optimize (nur manuell) shell: "mo optimize {{ '--dry-run' if mole_dry_run else '' }} </dev/null" changed_when: not mole_dry_run when: not unattended # Löschen Projektartefakte/Installer -> nur anzeigen, Entscheidung bleibt manuell - name: Projektartefakte und Installer anzeigen (Dry-Run) shell: "mo {{ item }} --dry-run </dev/null | tail -4" loop: [purge, installer] changed_when: false rescue: - name: Fehler merken (cleanup) set_fact: maint_failures: "{{ maint_failures + ['cleanup'] }}"`

## A report instead of a log to read

The `todo` list collects everything that needs a human: available macOS updates, outdated casks, the ansible stack that needs a manual upgrade, Brewfile drift, `brew doctor` warnings and sections that failed.

The expression guards its variables with `is defined` or `default()`, because with `--tags brew` the App Store tasks never ran and their results don't exist. In unattended mode a macOS notification appears only if the list is not empty. No notification means there is nothing to do.

`# ────────────────────────────────────────────── # Report # ────────────────────────────────────────────── - name: Freien Platz messen (nachher, KB) shell: df -k / | awk 'NR==2{print }' register: free_after changed_when: false tags: always - name: Handlungsbedarf ermitteln set_fact: todo: >- {{ (['macOS-Updates verfügbar'] if softwareupdate is defined and 'No new software available' not in (softwareupdate.stdout ~ softwareupdate.stderr) else []) + (['Casks veraltet: ' ~ (outdated_casks.stdout_lines | join(', '))] if outdated_casks.stdout_lines | default([]) | length > 0 else []) + (['Manuell upgraden (läuft sonst unter ansible): brew upgrade ' ~ (stack_outdated.stdout_lines | join(' '))] if stack_outdated.stdout_lines | default([]) | length > 0 else []) + (['Brewfile-Drift (brew bundle cleanup)'] if brew_drift.stdout | default('') | length > 0 else []) + (['brew doctor meldet Warnungen'] if brew_doctor.rc | default(0) != 0 else []) + (['Fehler in: ' ~ (maint_failures | join(', '))] if maint_failures | length > 0 else []) }} tags: always - name: Zusammenfassung debug: msg: "{{ ['Platz gewonnen: ' ~ ((free_after.stdout | int - free_before.stdout | int) // 1024) ~ ' MB (frei: ' ~ (free_after.stdout | int // 1048576) ~ ' GB)'] + (todo if todo | length > 0 else ['Alles erledigt, kein Handlungsbedarf']) }}" tags: always - name: macOS-Benachrichtigung bei Handlungsbedarf (nur unattended) command: argv: [osascript, -e, 'display notification "{{ todo | join("; ") }}" with title "Mac-Wartung"'] changed_when: false when: unattended and todo | length > 0 tags: always - name: Abbruch bei Fehlern (Exit-Code im Log) fail: msg: "Bereiche mit Fehlern: {{ maint_failures | join(', ') }}" when: maint_failures | length > 0 tags: always`

## Scheduling

The LaunchAgent is installed by the playbook itself. The block is tagged `schedule` and `never`, so it only runs with `--tags schedule`. It writes the plist, and reloads the agent with `launchctl bootout` and `bootstrap` only when the file changed. The job runs on Sundays at 10:00. If the Mac is asleep then, launchd runs it after wake-up. Output goes to `~/Library/Logs/mac-maintenance.log`.

One side effect: tasks tagged `always` still run with `--tags schedule`, so installing the agent also prints a report.

``# ────────────────────────────────────────────── # LaunchAgent (wöchentlich, Sonntag 10:00; holt verpasste Läufe nach dem Aufwachen nach) # Tag `never`: läuft nur mit --tags schedule # ────────────────────────────────────────────── - name: LaunchAgent installieren tags: [schedule, never] block: - name: Plist schreiben copy: dest: "{{ agent_plist }}" mode: "0644" content: | <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key><string>{{ agent_label }}</string> <key>ProgramArguments</key> <array> <string>/opt/homebrew/bin/ansible-playbook</string> <string>{{ playbook_dir }}/playbook-mac-maintenance.yml</string> <string>-e</string><string>unattended=true</string> </array> <key>WorkingDirectory</key><string>{{ dotfiles_dir }}/.ansible</string> <key>StartCalendarInterval</key> <dict><key>Weekday</key><integer>0</integer><key>Hour</key><integer>10</integer><key>Minute</key><integer>0</integer></dict> <key>StandardOutPath</key><string>{{ home }}/Library/Logs/mac-maintenance.log</string> <key>StandardErrorPath</key><string>{{ home }}/Library/Logs/mac-maintenance.log</string> </dict> </plist> register: agent_file - name: LaunchAgent neu laden shell: | launchctl bootout gui/$(id -u)/{{ agent_label }} 2>/dev/null launchctl bootstrap gui/$(id -u) {{ agent_plist }} when: agent_file.changed``

## Using it

You need Homebrew, `mas`, bun, nvm, pipx, Oh My Zsh and Mole installed. Remove the blocks for tools you don't use, and change `agent_label` and the `dotfiles_dir` and `brewfile` paths to your own.

© 2026 Simon Jenny [Blog (Englisch)](/blog/index.html) [Kontakt](/contact.html)

- [**{{title}}**  
  {{excerpt}}](%7B%7Burl%7D%7D)