Imported from Ziwi01/proveasio (
AGENTS.md). Install upstream withnpx skills add Ziwi01/proveasio. Copyright stays with the author.
Proveasio
Ansible-based provisioning for a developer workstation: Ubuntu (native or WSL2) is the primary target, with an experimental Windows-over-WinRM side. There is no application code here — the "product" is the set of roles, the software catalog, and the docs site.
Running it
Two requirements that are easy to get wrong:
cd ansible # ansible.cfg is discovered from CWD only
ansible-playbook -i inventory.yml setup-ubuntu.yml -K # -K is mandatory, -i is now optional
cd ansible/first. From the repo root, Ansible falls back to/etc/ansible/ansible.cfg.-Kis mandatory.ansible.cfgdeliberately does not setbecome_ask_pass: without a TTY it prompts, warns about echoed input, and silently accepts an empty password. CI gets away without-Konly because runners have passwordless sudo.ansible_become_exeingroup_vars/linux.ymlpicks/usr/bin/sudo.wswhen present. sudo-rs (defaultsudosince Ubuntu 25.10) wraps the-pprompt, and ansible-core < 2.22 never matches it, so-Kfails with a correct password. 24.04 has nosudo.ws, hence theis filefallback. Remove once a stable ansible-core ships ansible/ansible#86964.-i inventory.ymlis optional —ansible.cfg:3setsinventory = inventory.yml. Every doc and both workflows still pass it explicitly; keep doing so for clarity.
Useful subsets: --tags versions (re-resolve + prune), --tags config, --tags <tool>,
--skip-tags software. Set GITHUB_TOKEN or run gh auth login first — a full run makes
~33 GitHub API calls against a 60/hour unauthenticated limit.
Verifying your work
# Gate 1 (.github/workflows/pages.yml). Fails on broken internal links because
# docusaurus.config.js:24 sets onBrokenLinks: 'throw'.
cd docs-web && npm install && npm run build
# Gate 2 (.github/workflows/lint.yml). MANDATORY for any change under `ansible/` —
# the workflow is path-filtered to `ansible/**` and runs on push and pull_request.
# Must report `Passed: 0 failure(s)`; the tree is currently clean, so any finding is yours.
cd ansible && ansible-lint
# Cheap local proxy for the playbook. Does not catch errors in dynamic include_tasks
# (ansible-lint does — it is what caught the `enterntainment.yml` typo).
cd ansible && ansible-playbook -i inventory.yml setup-ubuntu.yml --syntax-check
There is exactly one ansible-lint config: ansible/.ansible-lint. It lives there
because ansible-lint discovers config from the CWD and every invocation is
cd ansible && ansible-lint. A second, divergent copy used to sit at the repo root and
silently produced a different result (111 findings vs 25) depending on where you ran from
— do not reintroduce it.
To silence a finding, prefer an inline # noqa: <rule> on the offending line with a
comment explaining why; that is the existing convention (see puppet.yml, nvm.yml,
docker.yml). Only add to skip_list when the rule conflicts with a project-wide
convention. roles/*/files/ is excluded because it is static payload copied to the
user's home, not Ansible code.
These do not exist — do not invent or "restore" them: npm run lint, npm run test,
any markdownlint runner, any Lua linter, make, just, pytest, pre-commit hooks.
.markdownlint.json and .luarc.json are editor-only settings. The only .lua file is
docker/nvim-install.lua, and nothing lints it.
There are two functional tests, both in CI. .github/workflows/build.yml is a full playbook
run on a clean Ubuntu 24.04 runner, on push to master and weekly. It is destructive —
never run the full playbook to "check" something on a real machine.
.github/workflows/docker.yml runs the playbook inside the image build, then
docker/test.sh; it has not run on GitHub yet. Locally, the smoke build (see "Docker
image") is the non-destructive functional check.
Architecture
Four roles, and that is all: common, config, software, windows. What looks like a
group of sub-roles is just task files inside one role's tasks/.
commonhas notasks/main.yml— it is a headless helper library, never run as a role. It is invoked ~85× viainclude_role+tasks_fromwithvars:as arguments. Four contracts:github_version.yml(resolve a version, set<app>_versionand<app>_url),save_version.yml(write tocurrent-versions.ymlviayq),config_file.yml(template-or-copy with backup),cleanup_versions.yml(prune old~/.local/optdirs).software(47 task files) installs things.config(10 task files) lays down dotfiles.windowsis independent and shares nothing with the rest.- Required tasks:
packages,yqandzsh(software) andzsh(config) have no exclude guard.[Software] Check that no required task is excluded(tagalways, right after the overrides sandwich) fails the run when an overrides file lists one of them; it checksconfig_tasks_excludetoo, because the config role runs an hour later. - Order is hard-wired, not declared:
setup-ubuntu.yml:51,54statically importssoftwarethenconfig. No role hasmeta/main.yml, so there are zero declared dependencies.configreads variables set bysoftware(sdkman_dir,node_version) — this only works becauseimport_roleis static and keeps vars in play scope, so it also works on--tags configruns.
Variables and overrides — the most important non-obvious thing
The project uses vars/main.yml, never defaults/main.yml. Role vars sit at
precedence 15, which beats group_vars (7) and play vars_files (14). A naive override
therefore cannot win.
The Linux path solves this by loading ansible/vars/overrides.yml with include_vars
(precedence 18) from inside the role — software/tasks/main.yml:32-39 and
config/tasks/main.yml:13-20, both with skip: true so the gitignored file is optional.
Because include_vars replaces a whole variable, pinning two tools under
github_packages: would wipe the other 31 entries. The fix is a sandwich at
software/tasks/main.yml:16-47: snapshot the catalogs → include_vars → combine() the
user's pins back on top (set_fact, precedence 19, outranks the include).
- Only three catalogs merge:
github_packages,pip_packages,docker_apt_packages. Every other override is still a full replace. - The merge is shallow —
combine()is called withoutrecursive=true. Equivalent for these flat dicts, but the commit message calling it "deep merge" is misleading. group_vars/holds connection plumbing only. All behavioural config lives in rolevars/.group_vars/windows.yml:3scrapes the Windows host IP out of/etc/resolv.conf. This breaks under WSL2 mirrored networking orgenerateResolvConf=false.
To change behaviour: personal/machine-local → ansible/vars/overrides.yml (gitignored).
Project-wide default → roles/<role>/vars/main.yml.
Version management
Flow: github_version.yml resolves <app>_version (skipping the API call entirely if the
version is pinned) → stat gate on ~/.local/opt/<app>-<version> → install into that
versioned dir → forced symlink into ~/.local/bin → save_version.yml → cleanup_versions.yml.
current-versions.yml is gitignored, generated, and write-only on native runs:
nothing in the playbook reads it (docker/test.sh compares against it in the image). It is a
per-machine receipt, written one key at a time by yq, never truncated, so it accumulates
stale keys. Do not hand-edit it, and do not paste it wholesale into overrides.
(.latest-versions.yml, a hand-maintained copy of the catalog that nothing read, was deleted.)
publish.sh is a maintainer release script. Do not run it. All versions have been latest
since 2.0.0; pins go in ansible/vars/overrides.yml.
Not everything resolves through GitHub: kubectl uses dl.k8s.io/release/stable.txt, apt
and pip tools use state: latest / --upgrade and read the version back, SDKMAN does not
support latest at all, and a few entries are hard-pinned.
Adding software: five edits minimum
roles/software/tasks/<tool>.yml: copyhunk.ymloreza.yml; they are the canonical shape.- Register in
roles/software/tasks/main.ymlwithwhen: "'<tool>' not in software_tasks_exclude"andtags: [software, versions, <tool>]. - Add the key to
github_packagesinroles/software/vars/main.yml. Required:common/tasks/github_version.ymllooks upgithub_packages[app]. - Add
check_software_<tool>(dashes become underscores) todocker/test.sh. Required: the Docker build fails for any selected include without a check. Config includes needcheck_config_<name>the same way. From the repo root,PROVEASIO_HOME="$PWD" bash docker/test.sh --coveragelists missing checks without building. - Update the docs (see below).
The app naming trap. Up to three spellings of the same tool coexist:
- filename, tag, and
software_tasks_excludekey → kebab-case (diff-so-fancy) github_packageskey and theapp:passed togithub_version.yml/save_version.yml→ snake_case (diff_so_fancy)- the
app:passed tocleanup_versions.yml→ the on-disk directory prefix, which may be a third form (rvm.ymlusesrvm1_ansiblefor lookup andrvm1-ansiblefor cleanup)
Get this wrong and the lookup errors or cleanup silently no-ops.
Other conventions: every shell: task sets args.executable: /bin/bash and starts with
set -e -o pipefail; version queries must use the injected gh_curl helper, never bare
curl, or they lose authentication; task names are prefixed "[Tool] ...".
Docker image
docker buildx bake (repo root) builds docker/Dockerfile from docker-bake.hcl. The playbook
runs inside the build; .github/workflows/docker.yml publishes it.
- Inputs, merged in this order by
docker/render-overrides.sh(yq *+: maps merge, lists append):docker/profile.yml(committed container defaults),docker/profile-<PROFILE>.ymlunlessPROFILEisfull(onlyslimis committed), and the gitignoreddocker/overrides.yml. Thensoftware_tasks_include/config_tasks_includefromdocker/overrides.ymltake names out of the exclude lists (Docker only; unknown names fail). The nativeansible/vars/overrides.ymlis excluded by.dockerignore.render-overrides.shoverwritesansible/vars/overrides.yml, so it has the samePROVEASIO_IMAGE_BUILD=1opt-in ascleanup.shbelow. - The slim profile keeps nvm, gvm and rvm: the default Neovim config installs Mason packages
with npm, go and gem, and mason-tool-installer retries missing ones on every start.
software/ansible.ymlalso needs nvm (npm installs the Ansible language server). docker/provision.shis the playbook step (bind-mounted withdocker/, not in the image): render the overrides; stop when the tags or excludes leave outsoftware/packages,software/yq,software/zshorconfig/zsh(it asksdocker/test.sh --list --tags ...); writedocker/build-info.env; apt upgrade; playbook;nvim-install.lua;cleanup.sh; zsh warm-up.docker buildx bake update(targetupdate,pull = false,PROVISIONED=update) builds theupdatestageFROM ${BASE_IMAGE}(default:IMAGE) and runsprovision.sh --update: it needsANSIBLE_TAGS, reuses the base image'sPROFILE, rejects new excludes, requires tools taken back with*_tasks_includeto be in the tags, skips the apt upgrade, and appendsUPDATES+=(...)tobuild-info.env.test.shthen tests everything the build's or any update's tags selected. Each update adds about 4 layers; replaced files stay below.docker/test.shruns in theteststage;finaldepends on it. It selects checks from the includes insoftware/configtasks/main.yml, the effective excludes and the build tags.docker/nvim-install.luaruns after the playbook and waits for Mason and treesitter installs. nvim gets the PATH of an interactive zsh, so Mason's npm, go and gem packages install. A failed package is logged, not fatal.- The image sets
TERM=xterm-256color(Docker's defaultxtermstrips the p10k colors) and an entrypoint (docker/entrypoint.sh) that warns when the working directory or~/.sshbelongs to another UID. The UID/GID are fixed at build time; docs passUSER_UID=$(id -u) USER_GID=$(id -g). Do not add a runtimechownof the home. docker/cleanup.shruns in the playbook layer. It must not delete paths the roles use as "already installed" gates. It exits 2 unlessPROVEASIO_IMAGE_BUILD=1. The Dockerfile sets it onprovision.sh, which unsets it and passes it only torender-overrides.shandcleanup.sh. Never set it on a workstation.REFRESHdefaults totimestamp(), so every local build re-resolveslatest. CI pins it per run.- Verify Docker changes with
docker buildx bake --printand a smoke build:ANSIBLE_TAGS=software_packages,yq,eza,zsh,neovim,neovim-config,docker IMAGE=proveasio:smoke docker buildx bake. It is the quick check (9 checks, about 5-7 minutes with cached bootstrap layers). Its 13 Mason failures are expected, because the tag set leaves out nvm, gvm and rvm. For update changes, also run an update on the smoke image:IMAGE=proveasio:smoke ANSIBLE_TAGS=eza docker buildx bake update(9 checks). - On WSL, check free host memory before a build. A full build pushed WSL to its memory cap;
on a host that also runs other large programs, that can exhaust Windows memory and Windows
then shuts WSL down. The margin used so far: start only with at least 10 GB free physical
memory and 10 GB free commit on the Windows host. Check both (values in KB) with
powershell.exe -Command "Get-CimInstance Win32_OperatingSystem | Select FreePhysicalMemory,FreeVirtualMemory". No guard script is committed.
Tags: what actually works
Tags are attached twice — outer tags: select whether the dynamic include_tasks runs at
all, apply.tags stamp the tasks inside it. Only the outer ones can select.
--tags cleanupand--tags sdkman_privilegeselect nothing — they exist only asapply:/inner tags, and that is deliberate: running them alone would skip the version resolution they depend on. Both work as--skip-tags; cleanup is reached via--tags versions.--tags software_packagesdoes work (it is on the outertags:of[Software] Install packages). Anything else you add must go on the outertags:to be selectable.--tags zshalso selects config's zsh and p10k tasks (both carry thezshtag), so--skip-tags zshskips all three. eza has no such coupling: its completion is linked to~/.zfunc/_eza, so--tags ezadoes not touch.zshrcand--skip-tags ezaskips only eza.- The
windowsrole has zero tags. Subset it withbundle_includeinstead. ansible-playbook setup-ubuntu.yml --list-tagsis the source of truth; keepdocs-web/docs/main/customization/50-partial-run.mdin sync with it.
Docs are part of the change
Every feat: commit touches docs-web/docs/ in the same commit. Adding a tool means
updating docs-web/docs/main/roles/10-software.md,
docs-web/docs/main/features/60-other-software.md, and the tag list in
docs-web/docs/main/customization/50-partial-run.md.
Edit docs-web/docs/ only. docs-web/versioned_docs/version-stable/ is regenerated by
rsync in publish.sh at release time — never hand-edit it. docs-web/README.md mentions
yarn; that is stale boilerplate, the project uses npm.
Commits and branches
Conventional Commits with a capitalized subject: fix(neovim): Restore Mason bootstrap.
Breaking changes use !. Work lands on develop; master is rebased from it at
release time. Docs deploy from develop, the full build runs on master.
CHANGELOG.md is generated by CI and auto-committed — never hand-edit it. TODO.md is
hand-maintained and uses the same type(scope): prefixes.
Deeper context
Serena memories hold the detail that does not belong in this file: ansible-architecture,
version-management, ci-and-verification, known-issues, docker-image. List and read
them when a task goes beyond what is above.
