Skip to content

Latest commit

 

History

History
687 lines (553 loc) · 40.2 KB

install-matrix.md

File metadata and controls

687 lines (553 loc) · 40.2 KB

Install matrix - roles, groups, personas, tools

Single source of truth for WHAT installs, HOW you select it, and HOW each tool is fetched. Read this before changing a role, adding a tool, or wiring a new persona.

Bottom line

Four levels, smallest to largest: tool -> group -> role -> persona. Every level is selectable the same way from Ansible and from install.sh (--tags <name>). The bare ./install.sh runs only the additive developer base; everything else is opt-in. Every tool is fetched at whatever version is current when the installer runs.

flowchart TD
    P["persona<br/>opinionated bundle"] -->|resolves to| R["role<br/>building block"]
    R -->|contains| G["group<br/>themed tag e.g. data, vpn-clients"]
    G -->|contains| T["tool<br/>one binary/app"]

    classDef persona fill:#0072B2,color:#fff,stroke:#333
    classDef role fill:#009E73,color:#fff,stroke:#333
    classDef group fill:#E69F00,color:#000,stroke:#333
    classDef tool fill:#F0E442,color:#000,stroke:#333
    class P persona
    class R role
    class G group
    class T tool
Loading

Snipe any level directly: --tags ripgrep (tool), --tags data (group), --tags infrastructure (role), --tags full-stack (persona). Everything is additive, so levels combine: --soe --languages rust,go.

Personas - the opinionated bundles

A persona is a meta-role: it carries no tasks, only meta/dependencies, so it resolves identically in Ansible and the CLI. Pick one, or none (the default).

Persona Flag Pulls in For
default (none) developer base only anyone - a clean, additive CLI base
languages --languages [langs] all toolchains, or a subset - --languages rust,go polyglot dev box
full-stack --full-stack developer + gui + node + typescript + python + infrastructure app dev, front-to-back
infra --infra developer + infrastructure SRE / platform
contributor --contributor developer + contributor (CI toolchain) outside contributor to a HyperI product
soe --soe developer + contributor + soe + soe-gui HyperI staff workstation

Everything combines. Personas, roles, groups and tools are additive tags, so you mix them freely: --soe --languages rust,go, --full-stack --tags infrastructure, --infra --tags vscode. --languages takes an optional comma list for a subset (--languages rust,go) or, bare, pulls every toolchain.

Persona wiring caveat. --tags developer OVERRIDES the never gate (the role tag sits on every base task), so a persona must resolve through its own meta-role tag, never by passing the raw developer tag - otherwise it drags in chrome/removals/etc. The default no-tags run is unaffected and stays additive.

Roles and their dependency nesting

Roles are the building blocks. The meta-dependency chain means selecting a higher role pulls the lower ones automatically - nobody memorises the list.

flowchart TD
    soe["soe<br/>+ soe-gui"] --> contributor
    contributor --> developer
    developer --> astral["astral suite<br/>uv + ruff + ty"]
    soegui["soe-gui"] --> astral
    soegui --> rdpclient["rdp-client"]
    dpy["developer-python"] --> astral
    dts["developer-typescript"] --> dnode["developer-node"]
    dlang["developer-languages"] --> drust["developer-rust"]
    dlang --> dgo["developer-go"]
    dlang --> dpy
    dlang --> dnode
    dlang --> dts
    dlang --> dc["developer-c"]

    classDef org fill:#0072B2,color:#fff,stroke:#333
    classDef base fill:#009E73,color:#fff,stroke:#333
    classDef lang fill:#E69F00,color:#000,stroke:#333
    classDef dep fill:#CC79A7,color:#000,stroke:#333
    class soe,soegui,contributor org
    class developer base
    class drust,dgo,dpy,dnode,dts,dc,dlang lang
    class astral dep
Loading
Role Tag Deps Opt-in
developer developer astral default (runs bare)
developer-gui developer-gui - opt-in
developer-rust / -go / -node / -c same (node: -) opt-in
developer-python developer-python astral opt-in
developer-typescript developer-typescript developer-node opt-in
developer-languages developer-languages all developer-<lang> opt-in (meta)
infrastructure infrastructure - opt-in
packer packer - opt-in, in no persona
contributor contributor developer opt-in
soe soe contributor opt-in
soe-gui soe-gui astral, rdp-client opt-in
developer-ai developer-ai - (probed, not declared) opt-in
rdp-server rdp-server - opt-in
rdp-client rdp-client - opt-in; pulled by default via soe-gui
vpn-clients vpn-clients - opt-in / soe default
astral (uv + ruff + ty) astral - base component; also dep of python/soe-gui
bash-modern bash-modern - opt-in (macOS)
vm_optimizer vm - opt-in
system_cleanup always - always (cache-clean only)

developer-ai's empty Deps column is not the same empty as the others. It has three hard dependencies - Claude Code, the codex binary, node - and declares none of them, because declaring them would be wrong twice over. Claude Code comes only from soe, so depending on it drags HyperI org policy onto a box that asked for a review tool; node comes from the developer base rather than developer-node, so the dependency that looks right supplies nothing. Whether Claude Code is there depends on what the target user's PATH reaches, which no role graph can answer. The run probes the target for all three instead and names the tag that fixes each miss.

Groups - themed tag bundles inside a role

A group is a themed tag over several tools in one role, so you can take the set without the whole role.

Group Tag Role Tools
ai ai developer-ai Codex CLI, the Codex plugin for Claude Code
cloudflare cloudflare infrastructure flarectl, wrangler
data data infrastructure clickhouse-client, confluent-cli, rpk, valkey-cli, vector
forgejo forgejo / codeberg soe tea (Forgejo/Gitea CLI)
rdp-client rdp-client rdp-client Remmina (Linux), Thincast (macOS)
vpn-clients vpn-clients vpn-clients OpenVPN 3, WireGuard, Tunnelblick (macOS)

Per-app tags stay too (--tags vscode, --tags slack, ...): run ./install.sh --list-apps for the full per-app list.

Tool matrix - install method per tool

Method is how the tool is fetched. Every install resolves the current version at run time, so there is no per-tool version policy to read here.

Digest verification is not universal. go and rustup fetch a checksum from their publisher and verify it. The rest rest on HTTPS and the release tag, which a publisher can in principle force-move. Extending checksum-at-download to the other manual binaries is the outstanding hardening (hyperi-ci #66).

Three fetches verify a digest IN THIS REPO, and they are the ones whose SHA is held here or read from a published manifest: the Go toolchain, rustup-init, and macbash (whose .sha256 sits beside the asset on downloads.hyperi.io, so the fetch also re-pulls a republished binary). A fourth, the Codex CLI on Linux, DELEGATES the check to a vendor script this repo runs unverified. That is a weaker position than the other three and is counted separately on purpose. Claude Code on Linux comes from Anthropic's signed apt/dnf repository, whose key is pinned by fingerprint like the other vendor repos.

Codex is the one where what is verified matters. The installer script has no published checksum of its own; what it verifies is its payload, the codex-package-<target>.tar.gz release tarball, against the published codex-package_SHA256SUMS before it unpacks anything. So the digest covers what lands on disk, not the script that puts it there, and the script is re-fetched and re-run unattended by the weekly hyperi-update timer on an soe box. That check is still most of why the role runs the installer rather than fetching the asset itself: the thin codex-<target>.tar.gz assets, the obvious thing to reach for instead, are not in that manifest and have no published digest at all.

developer (base, additive - runs bare)

Tool(s) Platforms Method
astral suite: uv, ruff, ty (uv bundles uv audit + uv check) all Fedora dnf / macOS brew; Ubuntu has no apt package (see Auto-update)
Python 3.14 for uv (astral_python_version), pinned as uv's global default; system python3 untouched all system python3 where it is 3.14+ (Fedora, Ubuntu 26.04), else uv python install; uv python pin --global per user
CLI utils (jq, bat, fzf, ripgrep, fd, git-delta, moreutils, miller, rsync, tmux, htop, wget, shellcheck, age, parallel, ...) all distro repo / brew
sd all distro (apt/dnf) / brew
yq (mikefarah; apt yq is kislyuk/yq, a different tool) all Fedora dnf / Ubuntu re-fetch (Tier 3) / brew
lazygit all Linux github-binary (Tier 3: re-fetch) / brew
docker (Engine on Linux, CLI-only on macOS) all vendor-repo / brew
git, git-lfs, git-filter-repo, gh all distro/PPA/brew
chrome, brave (opt-in never) all vendor-repo / cask
Homebrew (bootstrap) macOS vendor-script

developer-gui

Tool(s) Platforms Method
VS Code all vendor-repo / cask
Ghostty Fedora (COPR) / macOS (cask) vendor-repo / cask
Ghostty Ubuntu PPA (ppa:mkasberg/ghostty-ubuntu, key pinned by fingerprint); 24.04 is frozen at Ghostty 1.3.1 by the PPA, only 26.04 tracks new releases; a series the PPA does not publish takes the newest series it does
DBeaver all Ubuntu vendor-repo (dbeaver.io/debs) / Fedora flatpak / cask
VS Code privacy profile (opt-in: -e vscode_privacy_enabled=true) all bundled script (hyperi-vscode-privacy)

The privacy profile is off by default because it edits a personal settings.json. It merges one marked block of managed keys into VSCode, VSCodium and Cursor, leaving every comment and unmanaged key untouched, backs the file up once before its first write, and removes only its own keys under -e vscode_privacy_uninstall=true. A key the developer sets later in the same file keeps their value, and the run says which keys that applies to rather than reporting the profile applied when it changed nothing.

Languages

One role per language (developer-<lang>), each its own tag. developer-languages is the meta-role pulling them all.

developer-rust

Tool(s) Platforms Method
rustup + stable toolchain, rustfmt, clippy all vendor-script + rustup
cargo-binstall all cargo
cargo-* (nextest, deny, chef, bacon, update) all cargo
cargo-audit, cargo-hack, cargo-pgo all cargo
cargo-machete (unused deps), cargo-semver-checks (API breaks) all cargo
cargo-llvm-cov + llvm-tools-preview all cargo / rustup
protobuf-compiler Linux distro repo
librdkafka1, librdkafka-dev (Fedora: librdkafka-devel) Linux vendor-repo (Confluent clients repo)
mold, clang Linux distro repo
sccache all github-binary, latest each run (Tier 3) / brew
cargo-sweep all cargo-binstall / cargo
hyperi-rust-setup, hyperi-rust-cache-prune all role file -> /usr/local/bin
build governor (hyperi-rust-govern + rustbuild.slice) all role file + user unit

Every cargo tool is installed with --locked, and hyperi-update refreshes them with cargo install-update -a --locked. cargo install ignores the crate's published Cargo.lock by default and re-resolves, so without the flag a tool is built against dependency versions its author never tested -- and an update pass without it silently undoes the flag on every tool it touches.

cargo-binstall is installed for reaching for by hand. The tasks do not route through it: it fetches a publisher's prebuilt binary, where a lockfile has no meaning, so the defined --locked semantics of cargo install win over the faster fetch.

Coverage is cargo-llvm-cov. It replaced cargo-tarpaulin, which is ptrace-based and therefore Linux-x86_64 only -- a macOS developer had no coverage tool at all. The role uninstalls a leftover tarpaulin, because hyperi-ci prefers it when both are present and would otherwise keep Linux on the tool macOS cannot run.

Build cache caps are a sub-tag, rust-cache, so they can be applied without a full toolchain converge:

./install.sh --tags rust-cache

The build governor is the same shape (--tags rust-governor), and opt-OUT (-e rust_governor_enabled=false removes it). Concurrent cargo/rustc across several projects can OOM a host outright, so: the whole tree -- sccache-hosted compiles included -- runs inside rustbuild.slice with memory capped as a percentage of the host's RAM, at nice 19 and a CPU weight below the desktop's. No core is withheld and no job count is set: builds take every core until something else wants one. macOS has no cgroups and gets nice 19 alone -- no hard memory ceiling. HYPERI_RUST_GOVERNOR=off bypasses one invocation.

The rust-cache tag installs both tools, writes the caps, and schedules the prune (systemd timer on Linux, launchd agent on macOS). Sizes come from rust_cache_* in the role defaults; a build box overrides them per host.

developer-go

Tool(s) Platforms Method
go, delve all distro repo / brew
gopls all go install
golangci-lint* (Tier 3: re-fetch) all github-binary / brew
gosec, govulncheck all Fedora dnf / Ubuntu go install / brew

developer-python

The base ships the Astral suite (uv, ruff, ty) and uv bundles uv audit / uv check, so this role adds only the opt-in legacy type checker.

Tool(s) Platforms Method
mypy (opt-in; uv check/ty is the default) all uv-tool

developer-node

Tool(s) Platforms Method
eslint, prettier all npm global

The runtime is NOT here. node, npm, semantic-release, pnpm and corepack live in the developer base -- they are core tooling that semantic-release and CI need, so the base installs them and this role adds only the linters. That matters because it decides which tag fixes a missing node: developer, not developer-node. developer-ai's plugin gate says so in its warning, and this table used to say the opposite.

developer-typescript (dep: developer-node)

Tool(s) Platforms Method
typescript, tsx all pnpm global

developer-c

Tool(s) Platforms Method
C build tools (gcc, make, cmake, pkg-config) Linux distro; macOS CLT distro / xcode-select

infrastructure

Tool(s) Platforms Method
kubectl all vendor-repo (pkgs.k8s.io), the minor in infrastructure_kubectl_minor / brew
helm all Ubuntu vendor-repo (packages.buildkite.com) / Fedora dnf / brew
kubectx, kubens all Ubuntu apt universe / Fedora github-binary (Fedora packages neither) / brew
k9s all Fedora dnf / Ubuntu re-fetch (Tier 3) / brew
kind, argocd (Tier 3: re-fetch) all github-binary / brew
kustomize all Fedora dnf / Ubuntu re-fetch (Tier 3) / brew
checkov all uv-tool (Tier 2) / brew
terraform-docs (Tier 3: re-fetch) all github-binary / brew
aws-cli v2 all Fedora dnf (awscli2) / Ubuntu official snap / brew
aws-vault (Tier 3: re-fetch) all github-binary from the ByteNess fork / brew formula
opentofu (tofu) all vendor-repo (packages.opentofu.org), apt AND dnf / brew
openbao all vendor-repo (pkgs.openbao.org) / Fedora dnf / brew
azure-cli, google-cloud-cli all vendor-repo / cask
clickhouse-client, confluent-cli, rpk, valkey-cli, vector (the data group) Linux; macOS partial vendor-repo / distro
wrangler (the cloudflare group) all npm-global / brew
flarectl (the cloudflare group) all go install from source / brew

Almost every macOS path resolves to brew or a cask. The language managers that remain there carry no formula at all: alint and maid have none, and semantic-release needs its plugin set installed alongside it, which only npm gives.

Cloudflare publishes no flarectl binary and no distro packages it, so both platforms build it from source. It also lives on cloudflare-go's v0 branch -- from v4 that SDK is generated and carries no cmd/ directory. The Linux build needs a Go toolchain (--tags developer-go); without one the run records a warning and continues.

packer

Tool(s) Platforms Method
packer all vendor-repo (apt.releases.hashicorp.com, rpm.releases.hashicorp.com), signing key fingerprint-pinned / brew (hashicorp/tap)

The one HashiCorp tool installed: BUSL, with no open-source fork, so it is its own opt-in role rather than part of infrastructure. --tags removals takes terraform and vault, and removes this repo only where Packer is not installed.

contributor (CI toolchain - what hyperi-ci check drives)

* = blocking CI gate.

Tool(s) Platforms Method
hyperi-ci, semgrep all uv-tool (Tier 2) / brew
alint all cargo (Tier 2) / brew
osv-scanner all Linux re-fetch (Tier 3) / brew
gitleaks* all Fedora dnf / Ubuntu re-fetch (Tier 3) / brew
act all Fedora COPR / Ubuntu re-fetch (Tier 3) / brew
trivy all vendor-repo (official aquasecurity apt/dnf) / brew
hadolint* all Linux re-fetch (Tier 3) / brew
pip-audit* all uv-tool (Tier 2) / brew
kubeconform*, kube-linter all github-binary (Tier 3: re-fetch) / brew
yamllint, ansible-lint, pre-commit all distro (apt universe / dnf) / brew
actionlint all Linux re-fetch (Tier 3) / brew
vulture all Ubuntu apt / Fedora uv-tool (Tier 2) / brew
typos all cargo (Tier 2) / brew
maid (mermaid validator, used by /docs) all npm global (Tier 2)
git-scrub (git-history scrubber) all github-binary (Tier 3: re-fetch) / brew tap
macbash (macOS bash portability checker) all Linux downloads.hyperi.io binary (Tier 3, digest-verified) / brew tap

hyperi-ci is a Python tool from PyPI, installed via uv tool and refreshed to the latest release on every run (upgrade-if-present, not install-once). soe inherits it through its contributor dependency, so a staff box always has current hyperi-ci.

soe / soe-gui (HyperI org policy)

Tool(s) Platforms Method
Claude Code CLI (binary, tag claude) Linux Anthropic apt/dnf repo, soe_claude_channel (default stable; key pinned by fingerprint) / macOS cask vendor-repo / cask
Claude Code managed settings (tag claude-policy, soe only) all role file -> /etc/claude-code/
tea (Forgejo/Gitea CLI, forgejo / codeberg tags; gh is GitHub-only) Linux github-binary / macOS brew github-binary / brew
openvpn3 client (-> vpn-clients group) Fedora COPR / Ubuntu vendor-repo / macOS brew vendor-repo / brew
WireGuard, Tunnelblick (macOS) all / macOS distro / cask
Slack all vendor-repo / cask
LibreOffice (org office suite) Linux distro repo
Nemo, GNOME extensions (gext), fonts Linux distro / uv-tool / vendored
colima + Apple container (macOS only; container needs Apple silicon and macOS 26) macOS brew
Arcane container UI (opt-in soe_arcane_enabled; soe_arcane_long_session for a year-long login) all container image
Local ClickHouse + Redpanda (opt-in soe_local_services_enabled) all container image
removals / update_command / admin-scripts (opt-in never, on for soe) Linux tombstones + scripts

developer-ai (opt-in AI coding agents)

Tool(s) Platforms Method
Codex CLI Linux vendor-script (chatgpt.com/codex/install.sh; payload digest-verified)
Codex CLI macOS cask codex (there is no formula)
codex-plugin-cc, the Codex plugin FOR Claude Code all claude plugin install codex@openai-codex

Claude-first by design. Claude Code stays the driver and Codex is the adversarial reviewer, reached from inside Claude Code through OpenAI's own plugin. Nothing in this role installs Claude Code - that is soe's claude tag, which installs the BINARY AND NOTHING ELSE. The HyperI managed settings that used to ride along with it now sit behind claude-policy, which only soe selects, so --tags claude on a machine that is not ours no longer writes an unoverridable settings file into /etc. Codex ships several stable releases a week, so it moves faster than most of this matrix.

The plugin's dependencies are hard, and all three are per user: Claude Code, a codex that answers both codex --version and codex app-server --help, and node 18.18+ (its hooks run node <script>.mjs directly). Miss one and the run SKIPS the plugin and names the tag that fixes it - claude, codex, or developer for node, which is where node lives. Installing it regardless would leave slash commands that throw on every invocation, and a converge reporting success over them.

No context-compression proxy, deliberately. Headroom (headroomlabs-ai/headroom) was built into this role and taken back out before it shipped. It works by pointing ANTHROPIC_BASE_URL at a local proxy, and Claude Code gates capabilities on api.anthropic.com: behind any custom base URL it stops sending the context-1m beta header and accounts against 200k (headroom #1158), disables on-demand tool loading, inflating context by roughly 25K tokens (#746), and disables Remote Control outright (#1779, open, quoting Claude Code's own "Remote Control is only available when using Claude via api.anthropic.com"). Trading a 1M window for token compression is a losing trade. The 1M half is partly recoverable with a [1m]-suffixed model alias and tool search with ENABLE_TOOL_SEARCH=true; Remote Control is not. Check whether that gating still holds before anyone re-adds it.

Neither Codex nor the plugin works straight off a converge. Both need an interactive codex login (codex login --device-auth where there is no browser), which the deploy deliberately does not automate: it would put a per-user credential in the deploy path of a public repo.

Targeted deployment roles (opt-in, not in any persona)

Role Tag What it does
power-profile power-profile Sleep, idle and lid policy. always-on (default) never sleeps on mains power, lid shut included, and leaves battery behaviour stock; vm never sleeps at all, for an unattended RDP guest. Select with -e power_profile=<name>. Profiles are data files under roles/power-profile/vars/profiles/
rdp-server rdp-server GNOME Remote Login on port 3389 -- the system-wide feature driven by grdctl --system, NOT the per-user Desktop Sharing on the other Settings tab. Never overwrites credentials GNOME already holds. Includes handover-watchdog, which restarts the daemon after a failed handover leaves it accepting connections and servicing none, and window-fit, a GNOME Shell extension that refits windows into the work area so a reconnect at a smaller resolution cannot strand one off-screen
vm / optimizer vm VM guest optimisations (QEMU/SPICE agents)

Deliberately outside soe: the right power answer differs per machine, and imposing always-on on a laptop that lives in a bag cooks it.

Who the install targets

Two different users matter on any machine reached over SSH, and conflating them is the most expensive mistake available here.

  • The connecting user -- whoever Ansible authenticated as. On hyperi-infra's fleet inventory that is the service account ubuntu.
  • The desktop user -- whoever actually sits in front of the machine. hyperi_target_user names them; actual_user and user_home derive from it, and every user-scoped task keys off those.

They are the same person on a laptop, which is why the default is right there and wrong on a fleet machine.

One run, one user -- and how the loop works

The playbook applies user-level settings for exactly ONE user per run. There is no loop inside the roles, and that is deliberate: roughly 350 user-scoped references across 49 files means a per-task loop or a user-scope tag would eventually miss one silently, which is the failure mode this whole area already has a history of.

install.sh loops instead, once per user:

Selection Behaviour
default every real login account, discovered from /etc/passwd
--users a,b exactly those, in that order

Discovery skips three kinds of account: root; system accounts (uid below 1000, the systemd range above 60000, shells of nologin/false/sync); and the cloud image's own account, which exists to provision the machine rather than to work in.

That last one is read from system_info.default_user.name in /etc/cloud/cloud.cfg rather than matched by name, because it is ubuntu on an Ubuntu image and cloud-user on a Red Hat one. An override dropped in cloud.cfg.d is not consulted -- use --users on a machine that does that. --users is also how you deliberately include the cloud account.

No qualifying account means nothing is applied and the run stops, naming the reason. A fresh cloud image holding only root, system accounts and its own is exactly that case. There is no fallback to the invoking user on purpose -- falling back would write dotfiles into root or the provisioning account, which is what the criteria exist to prevent.

Driving Ansible directly (Packer, hyperi-infra) means writing the loop yourself:

for u in ubuntu hyperi; do
    ansible-playbook ... -e hyperi_target_user="$u"
done

The system-wide tasks are idempotent, so the second and later passes no-op on everything except that user's own files, and no user's settings overwrite another's.

Getting it wrong reports success. A task that writes to a home directory as the connecting user writes to the WRONG home and exits 0; the tools land where nobody looks. ansible.builtin.file is worse -- it cannot read across into the other home and reports "Insufficient permissions ... Treating as absent", a green tombstone over a file that is still there.

So become: false is a defect on anything user-scoped, because it means the CONNECTING user. The correct forms are:

Situation Form
Linux-only user-scoped task become: true + become_user: "{{ actual_user }}"
Cross-platform user-scoped task become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user }}"
macOS-only, or scoped to the invoker by definition become: false

The macOS split exists because Homebrew refuses to run as root, and macOS never separates the two users anyway. become_user is IGNORED while become is false, so become: false paired with become_user is always wrong.

The same trap sits in the molecule verify, which takes the desktop user from MOLECULE_TARGET_DESKTOP_USER -- asserting against the service account's empty home passes every user-scoped check on a host that was never fixed.

One install mode: latest, at the time you run it

Every tool resolves its version when the installer runs. A box built today gets what is current today, and the same command in four months gets what is current then. No release number for anything with an upstream to ask lives in this repo, and there is no flag that changes it.

Source Behaviour
distro or vendor repo state: present, and the repo carries the box forward on apt/dnf upgrade.
manual binary The newest GitHub release at least hyperi_release_min_age_days (7) days old, then the matching asset.
cargo / go tools cargo install X, go install X@latest.
go, rustup The publisher's current release, verified against the checksum it serves beside it.

Three things are deliberately not versions. Node's major (node_major) picks an LTS line rather than a release, and NodeSource's signed repo patches it in place. Rust's edition comes with whatever stable rustup installs, so edition 2024 needs no pin. kubectl's minor (infrastructure_kubectl_minor) picks the pkgs.k8s.io repository, which publishes one per Kubernetes minor and patches it in place. Left empty it follows the stable minor at https://dl.k8s.io/release/stable.txt. kubectl supports one minor either side of the API server, so this is the knob for cluster version skew. Set it to the minor of the clusters you work against, such as v1.36, and the next converge moves the repository and kubectl with it, up or down. A kubectl held with apt-mark hold or dnf versionlock add is left alone and the run warns.

GitHub releases wait 7 days. Every tool taken from a GitHub release installs the newest release at least hyperi_release_min_age_days old (default 7; set it in local-config/vars.yml or with -e), so a compromised or broken release has time to be pulled first. With none that old the installed copy stays and the run warns. Nothing newer is ever installed in its place. Releases from the hyperi-io org skip the wait (hyperi_release_cooldown_exempt).

go and rustup keep digest verification while tracking latest, by fetching the checksum from the publisher at install time -- go.dev/dl/?mode=json carries a SHA256 per file, and static.rust-lang.org serves rustup-init.sha256 beside the binary.

GitHub rate limits on an unattended build

Version resolution goes through api.github.com, which allows 60 requests an hour per IP anonymously. A run selecting infrastructure plus a couple of languages spends a good fraction of that by itself, and every machine sharing an egress address draws on the same 60 -- so a fleet rollout or an image build ends up competing with itself. The run that fails is rarely the run that spent the quota, and the failure surfaces as a 403 with the response headers dumped rather than anything saying "rate limited".

Set GITHUB_TOKEN (or GH_TOKEN) in the environment of whoever runs the playbook and every lookup sends it, lifting the ceiling to 5000/hour. Any GitHub account's token works and nothing is stored in the repo:

GITHUB_TOKEN=$(gh auth token) ansible-playbook ...

Without one the header is empty and every call stays anonymous, so a laptop install needs no token and behaves exactly as before.

Packaging ladder (pick the highest that works): distro repo (auto-updates) > official vendor apt/dnf repo > official snap/flatpak > manual binary (last resort, and the only rung that needs a SHA pin). The Method column above is the resolved channel per tool - chosen to be the highest auto-updating rung available.

How current is current enough. A repo that is ALMOST up to date is fine -- a few patches, or a minor, behind upstream. A channel that leaves the tool weeks or months behind is not, whatever rung it sits on. Measure before promoting, and measure the actual package, not the repo's reputation.

A rung only counts if it actually moves. "Distro repo" is two different things, and the difference decides most of the table:

  • Fedora updates packages within a release. kustomize matches upstream exactly, k9s matches, yq trails by a patch. Genuinely swept.
  • Ubuntu LTS universe freezes at release for five years. lazygit 0.57.0 against upstream 0.64.1, gitleaks 8.16.0 against 8.30.1. That is a pin wearing a repo's clothes.

So for a fast-moving tool a Tier 3 binary that hyperi-update re-fetches beats a universe package, and most distro promotions land on Fedora only. Check what a package actually ships before promoting a tool onto it -- and check it is the same tool at all. Three name collisions have already been ruled out: Ubuntu tea is a GUI text editor, Ubuntu yq is kislyuk/yq, and Fedora act is Autodesk's Automatic Component Toolkit.

Container format by distro: Ubuntu uses snap, Fedora uses flatpak. Neither is used where a repo, brew or cask can keep the tool current instead. What remains on a container format, and why nothing better exists: the aws-cli snap on Ubuntu (universe's awscli is v2 but frozen) and the DBeaver flatpak on Fedora (no vendor dnf repo). A strictly confined snap is ruled out for any tool that reads a repo: it sees only $HOME, so a checkout under /projects is invisible to it -- why actionlint and osv-scanner take the release binary. Ubuntu installs no flatpak app at all; the flatpak binary stays only so the tombstones in removals.yml can clear ones an older revision left behind.

A snap or distro package installs whatever its channel holds, which can trail upstream badly. That is why golangci-lint is a Tier 3 binary on Linux despite Fedora packaging it: the dnf build trails upstream by two minors, and a linter behind the Go toolchain cannot read the newer stdlib.

Moving a tool between channels leaves the old copy behind, and it wins. /usr/local/bin and /snap/bin both precede /usr/bin on the default PATH, so a binary superseded by a package shadows it forever and no update ever touches it. Every channel move needs a tombstone for the path it vacated, and the remediation molecule scenario asserts the replacement is what which resolves to -- not merely that the new thing installed.

A hand install leaves a second copy too. The removals tag clears copies of the managed tools in ~/.local/bin and ~/go/bin where the managed copy exists and is a different file. A deliberate pin there (an older kubectl, golangci-lint v1) goes too, so pin per project instead. Tools whose name another program also uses (yq, tea, sd, act) are left alone. It also purges the upstream .deb or .rpm of macbash, git-scrub, golangci-lint or k9s installed beside the /usr/local/bin copy, unless a repository offers a package of that name.

Dropped tools leave on --tags removals. dive (no release since 2025-05) and gron (none since 2022) are no longer installed. A removals or soe run clears the /usr/local/bin copies older revisions put there and Ubuntu's gron package. A Mac keeps its brew formulae, as with every other retired CLI utility.

developer-go links go and gofmt into /usr/local/bin on every run, and on a removals or soe run also removes the distro Go once a working, self-contained /usr/local/go is in. developer-rust retires cargo-tarpaulin on every run, and on a removals or soe run clears cargo-home duplicates and, where the host exports a relocated CARGO_HOME, the old ~/.cargo/bin binaries the effective home also holds. A package something else depends on stays, and the run says which. Its direct dependencies that a purge would orphan are marked manually installed, so hyperi-update's autoremove does not take them later.

Auto-update

Every tool stays current; the mechanism depends on its channel. Three tiers:

Tier 1 - OS-swept. Tools from a distro package, an official vendor apt/dnf repo, or an official snap are refreshed by the host's own updates: unattended-upgrades / dnf-automatic on Linux, snapd auto-refresh, or brew upgrade on macOS. The majority of tools. Nothing HyperI-specific runs.

Swept does not mean current: an Ubuntu universe package is refreshed only for security, so it holds the version it shipped with for the life of the release (see the ladder note above). A vendor repo, a snap and Fedora's own packages all track upstream properly.

Tier 2 - language-manager tools. Tools installed by uv / cargo / go / npm / pnpm have no OS channel, so hyperi-update refreshes them through each manager: uv tool upgrade --all, rustup update and cargo install-update -a --locked, go install ...@latest for the tools in ~/go/bin (only when the module or the Go toolchain moved), npm update -g, and pnpm update -g --latest. The pnpm globals (eslint, prettier, typescript, tsx) are installed unpinned, so --latest moves them to what a fresh converge would install, majors included. uv python upgrade moves each uv-managed Python to its newest patch without adding a python or python3 shim, and leaves the superseded patch installed, because uv has no command that removes only those. E.g. ruff, ty, semgrep, pip-audit, cargo-audit, cargo-hack, typos, govulncheck, maid.

Tier 3 - static binaries. A handful ship only as a release binary with no repo, snap, or language manager: kind, argocd, kubeconform, kube-linter, terraform-docs, golangci-lint, lazygit, actionlint, osv-scanner, aws-vault, git-scrub, sccache, fnm, hadolint, tea and macbash on both distros, k9s, kustomize, yq, gitleaks and act on Ubuntu, and sd, kubectx and kubens on Fedora. hyperi-update re-fetches each one only where the role put it in /usr/local/bin on that distro, so the binary cannot shadow a packaged copy. Each download must match the sha256 GitHub publishes for the asset, or the release's checksum file where there is no digest (tea and macbash publish a .sha256 beside the asset), and is renamed into place so the working copy is never half-written. Each GitHub release is the newest at least 7 days old, as in the roles, which write their age and exempt orgs to /etc/default/hyperi-update. HYPERI_RELEASE_MIN_AGE_DAYS in the environment overrides that file and --min-age DAYS overrides both. With no release old enough the installed copy stays and the summary lists it. A stamp in /var/lib/hyperi-update stops an unmoved release being downloaded again, and the GitHub API is asked with the last ETag, so with a token set an unchanged release costs no rate limit (an anonymous 304 still counts). When the API refuses, the release document from the last run stands in. On Ubuntu, uv and uvx in ~/.local/bin are refreshed the same way as the invoking user.

golangci-lint is here for a different reason: Fedora does package it, but the build trails upstream, and a linter behind the Go toolchain cannot read the newer stdlib.

Codex fits no tier cleanly. On macOS it is Tier 1: the codex cask, swept by brew upgrade. On Linux there is no repo, no snap and no language manager, and it is not Tier 3 either - the release asset is a package TREE that has to be staged and symlinked, not a bare binary the re-fetch helpers could drop into /usr/local/bin. Its update path is re-running the official installer, which resolves the current release, verifies the payload digest and short-circuits when what is staged is already current. hyperi-update does exactly that, guarded on codex being present.

The Codex plugin is a channel of its own again: claude plugin update codex@openai-codex, which the package manager does not reach because it moves the CLI and not its marketplace plugins.

hyperi-update (the "update my system" command) runs all three tiers and the two out-of-tier channels above, plus the OS / snap / flatpak sweep, so one command brings everything current. soe schedules it on a timer; the base leaves update cadence to the host.

Checking what a host actually has

tools/hyperi-doctor is the read-only counterpart to everything above. It resolves the same role graph documented here, recursing through each role's meta/main.yml dependencies, then reports which declared apt / dnf / homebrew packages are missing from this host. It applies nothing and needs no sudo.

It also reads the applied-state stamp (/var/lib/hyperi-developer/applied.json), written at the end of a run, and compares its git SHA against the current checkout -- so "this host has not been reprovisioned in a month" is answerable rather than something you find out through a build failure in an unrelated dependency.

Package names built from a loop, a variable or Jinja cannot be resolved by a static scan of the task files, and it reports that count explicitly. A gate that quietly under-reports is worse than no gate.

Design intent - an additive base

The developer base aims to be a lightweight additive base that leaves your existing tools and configuration alone. It does not remove tools you already have or apply HyperI org policy - those moved to soe and to explicit opt-in tags. This is a design goal, not a guarantee: consistent with the Apache-2.0 "AS IS, without warranties" stance, we make no assurances. The base is not zero-footprint - it adds the Docker repo and group, a Git PPA on Ubuntu, and Flathub when GUI apps are requested.

What lives off the default path (never, and soe for HyperI machines): tombstones/removals, the branded update app, /usr/local/sbin admin scripts, the Fedora podman purge (cut to the conflicting podman-docker only), and the full dist-upgrade (gated behind hyperi_system_upgrade, default cache-clean).