devmachine

How it works

One package on many systems

A machine runs Debian, Ubuntu, Arch Linux or macOS. A package is written once for all of them: never gh-debian and gh-arch. It carries a file per system only where that system really differs, and says in platforms which systems it runs on. This page is for whoever writes a package: how the built-in packages do it, and the traps they already hit.

Load the names for this system

A package that needs different names or paths per system keeps them in vars/, and loads the first file that matches, most specific first:

# A loop over first_found, not a plain include_vars: when no file matches, the
# loop is empty and the task skips, on ansible-core 2.14 as on the latest.
- name: Load the names for this system
  ansible.builtin.include_vars: "{{ devmachine_vars_file }}"
  loop: "{{ query('ansible.builtin.first_found', devmachine_vars_chain) }}"
  loop_control:
    loop_var: devmachine_vars_file
  vars:
    devmachine_vars_chain:
      files:
        - "{{ ansible_facts['distribution'] }}.yml"
        - "{{ ansible_facts['pkg_mgr'] }}.yml"
        - "{{ ansible_facts['os_family'] }}.yml"
        - default.yml
      paths:
        - "{{ role_path }}/vars"
      skip: true

What each name matches:

FileMatches
Ubuntu.yml, Debian.yml, Archlinux.yml, MacOSX.ymlone distribution (distribution)
apt.yml, pacman.yml, homebrew.yml, macports.ymlone package manager (pkg_mgr)
Debian.yml, Archlinux.yml, Darwin.ymla family (os_family): Debian.yml serves Ubuntu too
default.ymleverything else

A package with no difference between systems has no vars/ file and no such task. Defaults that hold everywhere stay in defaults/main.yml, and the files in vars/ only change what differs.

Read facts as ansible_facts['...'], never as the ansible_* variables.

How to install a tool

Take the first of these that works for the tool:

  1. The project’s official installer, such as curl -fsSL https://mise.run | sh.
  2. A release binary for the system and the architecture, from the project’s releases.
  3. mise, for a language or a tool mise knows.
  4. The package module, with the names in vars/. Never apt: the CLI refuses a package that uses it.
  5. tasks/<System>.yml, only when the steps themselves differ, not only the names.

A release binary is named the way Go names systems and architectures, not the way the facts do. ansible_facts['system'] | lower gives linux or darwin. The architecture needs a map: Linux reports x86_64 or aarch64, an Apple Silicon Mac reports arm64, and a release asset says amd64 or arm64. The glab package carries the map:

devmachine_go_architectures:
  x86_64: amd64
  aarch64: arm64
  armv7l: armv6
devmachine_go_architecture: >-
  {{ devmachine_go_architectures.get(ansible_facts['architecture'], ansible_facts['architecture']) }}

Names across systems

What the built-in packages install, by system. Check a name before you use it (see below); this table is a start, not the source.

WhatDebian, UbuntuArch LinuxHomebrewMacPorts
build toolchainbuild-essentialbase-develthe Command Line Toolsthe Command Line Tools
curl, git, tar, unzip, zshsame namesame namecome with macOScome with macOS
SSH clientopenssh-clientopensshcomes with macOScomes with macOS
ACLs for Ansible’s becomeaclaclnot needednot needed
tmux, mosh, ffmpegsame namesame namesame namesame name
GnuPGgnupggnupggnupggnupg2
OpenSSL headerslibssl-devopensslopenssl@3openssl3
zlib headerszlib1g-devzlibzlibzlib
YAML headerslibyaml-devlibyamllibyamllibyaml
readline headerslibreadline-devreadlinereadlinereadline
libffi headerslibffi-devlibffilibffilibffi
GMP headerslibgmp-devgmpgmpgmp
DockerDocker’s repositorydocker, docker-buildx, docker-compose——

Arch ships headers inside the library package, so there is no -dev to add. Homebrew keeps openssl@3, readline, zlib and libffi out of its prefix (keg-only): a build finds them through brew --prefix <name>.

Traps already hit

Each of these broke a real run before the packages handled it.

Swap on btrfs. fallocate and swapon fail with swapon: /swapfile: swapon failed: Invalid argument when / is btrfs, as on Arch. Make the file with btrfs filesystem mkswapfile there, and fallocate elsewhere. macOS manages its own swap: base makes none on a Mac.

No LANG over SSH on Arch. Nothing reads /etc/locale.conf for an SSH login, and mosh-server stops with mosh-server needs a UTF-8 native locale to run. base writes LANG to /etc/environment, which pam_env reads for every login.

distribution_release is NA on Arch. Arch is a rolling release. A repository line built from the release name (Docker’s, Tailscale’s) belongs in vars/Debian.yml, never in a task every system runs.

No cache refresh alone on Arch. pacman -Sy without -u is a partial upgrade, which Arch does not support. Refresh the lists only together with an upgrade, and only when the operator asked for one.

No group named after the account on macOS. Every account is in staff (gid 20); group: alice fails with chgrp failed: failed to look up group alice. The root group does not exist either: a file root owns takes wheel. Read the account’s home and group from the account itself, with the user module in check mode, which reads and never creates:

# check_mode makes the user module only read: it never creates the account,
# and a missing one just leaves no home in the result.
- name: Read the account's home and group
  ansible.builtin.user:
    name: "{{ devmachine_workspace.user }}"
  check_mode: true
  changed_when: false
  register: devmachine_account

Then use devmachine_account.home | default('/home/' ~ devmachine_workspace.user) for the home, and devmachine_account.group | default(omit) for the group. Every package that reads devmachine_account registers it itself. A registered variable outlives the role, so a package that skips this task reads whatever account the previous package left, which can belong to another workspace. packages validate warns about such a package. macOS has no getent, and homes are in /Users.

Homebrew refuses root. On a Mac reached over SSH the play runs as root, and brew refuses to. A task that installs through Homebrew becomes the account that owns it:

- name: Find who owns Homebrew
  ansible.builtin.stat:
    path: "{{ '/opt/homebrew' if ansible_facts['architecture'] == 'arm64' else '/usr/local' }}/bin/brew"
  register: devmachine_base_brew
  when: ansible_facts['pkg_mgr'] == 'homebrew'

- name: Install the base packages from Homebrew
  ansible.builtin.package:
    name: "{{ devmachine_base_packages }}"
    state: present
  become: "{{ devmachine_base_brew.stat.uid != ansible_facts['effective_user_id'] }}"
  become_user: "{{ devmachine_base_brew.stat.pw_name }}"
  when: ansible_facts['pkg_mgr'] == 'homebrew'

MacPorts is the other way round: it installs as root, so the play stays root.

MacPorts has no check mode. community.general.macports does not support check mode, so sync --check skips its tasks and shows nothing they would install. Do not read an empty dry run as “nothing to do”.

A plain SSH command on a Mac has a short PATH. It gets /usr/bin:/bin:/usr/sbin:/sbin; Homebrew (/opt/homebrew/bin or /usr/local/bin) and MacPorts (/opt/local/bin) are added only by a login shell. Call a tool by its absolute path, or set PATH in the task’s environment.

platforms. A package that cannot work on a system says so: firewall, fail2ban, caddy and docker declare platforms: [linux], mac-brew and mac-ports declare [macos]. sync refuses a package for another system before it changes the machine, naming both. Left out, the package claims to run everywhere, so leave it out only when that is true.

Check, never guess

A module’s options, a package name and a fact’s value differ by system and by version. Look them up:

WhatHow
a module’s options and its check-mode supportansible-doc <module>, or docs.ansible.com
a Debian or Ubuntu packageapt-cache policy <name>
an Arch packagepacman -Si <name>
a Homebrew formulabrew info <name>
a MacPorts portport info <name>
what the machine reportsdevmachine --format json machines show <machine>, under observed

Prove it on every system it declares

A package that says platforms: [linux, macos] is proved on Debian or Ubuntu, on Arch and on a Mac, not on one of them. Each proof is the same: devmachine sync ends with failed=0, and a second sync reports changed=0. A machine for this is cheap: devmachine machines create-local <name> makes an Ubuntu VM, and --distro arch an Arch one.