In the previous post, I extended the Ansible playbook to install eza from an external apt repository. Everything worked in the container. Time to run it on my actual WSL2 machine.
chezmoi updateNothing happened. No Ansible output. No errors. The playbook didn’t run at all.
Bug #1: The Missing .tmpl#
The onchange script was supposed to re-run whenever the playbook changed. That’s how we set it up — a {{ include "ansible/playbook.yml" | sha256sum }} comment that embeds a hash of the playbook. Change the playbook, the hash changes, chezmoi sees new script content, and re-runs it.
Except the hash wasn’t changing. Because it wasn’t being calculated at all.
The problem was the filename. I had:
run_onchange_after_ansible-setup.shBut for chezmoi to evaluate template expressions like {{ ... }}, the file needs the .tmpl suffix:
run_onchange_after_ansible-setup.sh.tmplWithout .tmpl, chezmoi treats the file as plain text. The {{ include ... | sha256sum }} line is just a comment — a literal string that never changes. So run_onchange_ has nothing to trigger on. The script content is always the same, and chezmoi skips it.
Adding .tmpl fixed it. The hash was now evaluated, and the script started triggering inside my container.
Bug #2: Ansible Can’t Become Root#
With the template fix in place, I ran chezmoi update on WSL2 again. This time the playbook ran — and failed. Ansible complained that it couldn’t escalate privileges.
The issue is straightforward. Inside the dev container, I’m already root. The become: true in the playbook is a no-op — you’re already there. But on WSL2, I’m running as a regular user. Ansible needs to use sudo to become root, and sudo needs a password.
The fix is -K (short for --ask-become-pass). It tells Ansible to prompt for the sudo password before attempting to escalate:
ansible-playbook -K --connection=local --inventory 127.0.0.1, \
"${CHEZMOI_SOURCE_DIR}/ansible/playbook.yml"Now chezmoi update triggers the playbook, Ansible asks for the sudo password, and the installation completes. Works in the container (where -K is simply ignored since you’re already root) and on WSL2.
Hashing the Entire Ansible Directory#
With both bugs fixed, I started thinking about the future. Right now the playbook is a single file. But as I add more tools, it’ll naturally split — roles, variable files, maybe task includes in subdirectories. The current setup only watches ansible/playbook.yml. Add a new role file and the script won’t re-run.
Chezmoi templates are Go templates with chezmoi-specific extensions. That gives us access to functions like glob and range, which is enough to solve this. Here’s the updated script:
#!/bin/bash
# ansible dir hash:
{{ range (glob (joinPath $.chezmoi.sourceDir "ansible/**/*")) -}}
# {{ . | trimPrefix (joinPath $.chezmoi.sourceDir "/") }}: {{ include . | sha256sum }}
{{ end -}}
set -euo pipefail
ansible-playbook -K --connection=local --inventory 127.0.0.1, \
"${CHEZMOI_SOURCE_DIR}/ansible/playbook.yml"Let’s break down what the template block does:
glob (joinPath $.chezmoi.sourceDir "ansible/**/*")— finds every file in theansible/directory, recursively. ThejoinPathbuilds the full path from chezmoi’s source directory.range— iterates over each matched file, like aforloop.trimPrefix— strips the source directory path from each filename, so the output shows clean relative paths likeansible/playbook.ymlinstead of the full absolute path.include . | sha256sum— reads each file’s contents and calculates its SHA-256 hash.- The
-after}}and before{{trims whitespace, keeping the output clean.
Each file gets its own line in the rendered script with its path and hash. When any file in the ansible/ directory changes — or a new file is added — the rendered script content changes, and chezmoi triggers a re-run.
Looking Under the Hood#
This raised a question. The template itself never changes — only its output does when the ansible files change. So how does chezmoi track what the output looked like last time?
chezmoi state#
Chezmoi maintains a local state database (a BoltDB file at ~/.config/chezmoi/chezmoistate.boltdb). You can inspect it with chezmoi state dump. The relevant section for onchange scripts is scriptState:
"scriptState": {
"96ca8f0bc2450d053185aa8eaf419e7008b8528a631bf924318ccad2a2e9b99d": {
"name": ".chezmoiscripts/ansible-setup.sh",
"runAt": "2026-03-04T06:23:05.952507492Z"
},
"c8be05c5052430ee4b32947d216898054799641c71e4610c5d349868f79c8536": {
"name": ".chezmoiscripts/ansible-setup.sh",
"runAt": "2026-03-07T07:01:28.056749755Z"
}
}Each entry is keyed by the SHA-256 hash of the rendered script content at the time it ran. When chezmoi renders the template again during chezmoi apply or chezmoi update, it computes the hash of the new output and checks whether that hash exists in scriptState. If it does — the script has already run with this exact content, so it’s skipped. If it doesn’t — something changed, and the script runs again.
That’s the mechanism behind run_onchange_. It’s not watching files directly. It’s comparing rendered output hashes.
chezmoi execute-template#
There’s another tool that helps here: chezmoi execute-template. It renders a template and prints the result without executing anything. You can pipe a template file into it to see exactly what chezmoi would produce:
chezmoi execute-template < ~/.local/share/chezmoi/.chezmoiscripts/run_onchange_after_ansible-setup.sh.tmpl#!/bin/bash
# ansible dir hash:
# /ansible/playbook.yml: 16124a57999d17cf13726b2304d7ba52e0a581f609638925da019e48b580a669
set -euo pipefail
ansible-playbook -K --connection=local --inventory 127.0.0.1, \
"${CHEZMOI_SOURCE_DIR}/ansible/playbook.yml"There’s the rendered output. Right now there’s one file with one hash. As I add more files to the ansible/ directory — roles, variable files, task includes — each one will appear here with its own hash. The template holds the recipe; chezmoi evaluates it fresh every time. If any hash changes, or a new file appears, the overall script content changes and the playbook re-runs.
What We’ve Achieved So Far#
After chezmoi init --apply, a fresh machine now has:
- zsh installed and set as default shell
- The correct version of age, installed from source
- SSH keys decrypted and in place
- Git identity configured for the dotfiles repo
- SSH key linked to git, remote switched to SSH
- Ansible installed and used to manage software packages
- tmux installed via Ansible
- eza installed from an external apt repository via Ansible
- Onchange script that watches the entire
ansible/directory for changes - Sudo password support for non-root environments
What’s Next#
Today started with a silent failure and ended with a deeper understanding of how chezmoi templates and state tracking actually work. A missing .tmpl suffix. A missing -K flag. Small mistakes, but fixing them opened the door to a more robust setup — one that watches an entire directory of Ansible files, not just a single playbook.
Next, I want to tackle fzf. The version in Debian’s repos is too old to be useful, so we’ll need a different approach — downloading a binary release directly from GitHub.
