In the previous post, I fixed the onchange script so the Ansible playbook actually triggers when it should. At the end, I mentioned fzf as the next tool to install. Here’s why it needed a different approach.
Three Installation Strategies So Far#
Over the last few posts, we’ve built up three ways to install software through chezmoi:
- Shell script with direct download — for age, where we needed a specific version from GitHub and had to do it before chezmoi could decrypt anything
- apt install via Ansible — for tmux, where the Debian repos had exactly what we needed
- External apt repository via Ansible — for eza, where the package didn’t exist in Debian’s repos but the project maintained their own
Now there’s a fourth scenario. fzf is in Debian’s repos, but the version is so old it’s missing features I actually want. And unlike eza, fzf doesn’t maintain its own apt repository. What it does have is a GitHub release — a prebuilt binary you can download and drop into /usr/local/bin.
Why Not Use the Shell Script Approach?#
We already download age from GitHub releases in a shell script. Why not do the same for fzf?
Because age is a special case. It runs before Ansible is available — it’s needed to decrypt secrets during the initial chezmoi init --apply. The shell script exists out of necessity, not preference.
For everything else, Ansible is the better tool. It handles idempotency, gives us clear task names, and keeps all software installation in one place. Mixing shell scripts and Ansible for the same class of problem — “download a binary from GitHub” — would mean maintaining two different approaches for the same pattern.
The Ansible Tasks#
Here’s what I added to the playbook:
- name: Install fzf from GitHub release
block:
- name: Get latest fzf version from GitHub
ansible.builtin.uri:
url: https://api.github.com/repos/junegunn/fzf/releases/latest
return_content: true
register: fzf_release
- name: Download and extract fzf binary
ansible.builtin.unarchive:
src: "https://github.com/junegunn/fzf/releases/download/{{ fzf_release.json.tag_name }}/fzf-{{ fzf_release.json.tag_name | regex_replace('^v', '') }}-linux_amd64.tar.gz"
dest: /usr/local/bin
remote_src: true
mode: '0755'
creates: /usr/local/bin/fzfTwo tasks. Let’s unpack what’s new.
uri and register#
- name: Get latest fzf version from GitHub
ansible.builtin.uri:
url: https://api.github.com/repos/junegunn/fzf/releases/latest
return_content: true
register: fzf_releaseThe uri module makes HTTP requests. Here it hits GitHub’s releases API, which returns JSON describing the latest release — including the tag name (like v0.60.3).
return_content: true tells Ansible to capture the response body, not just the status code. And register saves the entire response into a variable called fzf_release that subsequent tasks can reference.
This is the first time we’ve used variables in the playbook. So far every task has been self-contained — install this package, create this file. Now one task’s output feeds into another.
unarchive with remote_src#
- name: Download and extract fzf binary
ansible.builtin.unarchive:
src: "https://github.com/junegunn/fzf/releases/download/..."
dest: /usr/local/bin
remote_src: true
mode: '0755'
creates: /usr/local/bin/fzfThe unarchive module extracts archives. By default, it expects a local file. Setting remote_src: true tells it to download the archive from a URL first, then extract it. One task handles both the download and the extraction.
The creates parameter works the same way we saw with the eza GPG key — if /usr/local/bin/fzf already exists, skip the task entirely. This makes it idempotent without us having to write any conditional logic.
regex_replace#
fzf-{{ fzf_release.json.tag_name | regex_replace('^v', '') }}-linux_amd64.tar.gzGitHub tags the release as v0.60.3, but the archive filename uses 0.60.3 without the v prefix. The regex_replace filter strips it. It’s a Jinja2 filter — the same template engine Ansible uses throughout — and the ^v pattern matches a v only at the start of the string.
The Full Playbook#
Here’s the complete playbook with all three installation strategies side by side:
---
- name: Setup workstation
hosts: localhost
connection: local
become: true
tasks:
- name: Install tmux
ansible.builtin.apt:
name: tmux
state: present
when: ansible_os_family == "Debian"
- name: Install eza
block:
- name: Install gpg (required for keyring setup)
ansible.builtin.apt:
name: gpg
state: present
- name: Ensure /etc/apt/keyrings directory exists
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Download and dearmor eza GPG key
ansible.builtin.shell:
cmd: >
curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc
| gpg --dearmor -o /etc/apt/keyrings/gierens.gpg
creates: /etc/apt/keyrings/gierens.gpg
- name: Add eza apt repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main"
filename: gierens
state: present
- name: Set correct permissions on keyring and sources list
ansible.builtin.file:
path: "{{ item }}"
mode: '0644'
loop:
- /etc/apt/keyrings/gierens.gpg
- /etc/apt/sources.list.d/gierens.list
- name: Install eza
ansible.builtin.apt:
name: eza
state: present
update_cache: true
- name: Install fzf from GitHub release
block:
- name: Get latest fzf version from GitHub
ansible.builtin.uri:
url: https://api.github.com/repos/junegunn/fzf/releases/latest
return_content: true
register: fzf_release
- name: Download and extract fzf binary
ansible.builtin.unarchive:
src: "https://github.com/junegunn/fzf/releases/download/{{ fzf_release.json.tag_name }}/fzf-{{ fzf_release.json.tag_name | regex_replace('^v', '') }}-linux_amd64.tar.gz"
dest: /usr/local/bin
remote_src: true
mode: '0755'
creates: /usr/local/bin/fzfThree strategies, one playbook. Each tool is installed the way that makes sense for its situation.
Testing It#
Same container workflow:
podman run -it --rm debian:bookworm-slim bash
apt update -y && apt upgrade -y && apt install curl git nano -y
mkdir -p ~/.config/age
chmod 700 ~/.config/age
touch ~/.config/age/dotfiles-repo-key.txt
chmod 600 ~/.config/age/dotfiles-repo-key.txt
nano ~/.config/age/dotfiles-repo-key.txt # paste in key
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply knuth-info
zshAfter chezmoi finishes:
fzf --version # should print something like 0.60.3If the version is recent and not 0.38 (what Debian bookworm ships), the GitHub release approach worked.
A Reusable Pattern#
This two-task pattern — hit the GitHub API, download and extract — works for any project that publishes prebuilt binaries. The only things that change per tool are:
- The GitHub
owner/repoin the API URL - The archive filename pattern (some use
.zip, some use different naming conventions) - The
destpath andcreatespath
If I need to install another tool this way in the future, it’s a copy-and-adjust job. And if the list grows long enough, it could become an Ansible role with variables for each tool. But that’s a bridge to cross when we get there.
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
- fzf installed from a GitHub binary release via Ansible
What’s Next#
We now have four ways to get software onto a fresh machine — shell scripts for bootstrap dependencies, apt for standard packages, external repos for third-party packages, and GitHub releases for everything else. That covers most of what you’ll encounter in the wild.
With the installation story largely complete, it’s time to shift focus. The tools are there — now they need configuring. tmux, fzf, and eza all have configuration that should live in the dotfiles. Time to start making them work the way I want.
