Declarations and code that build this workstation: one repository declares the environment; small modules apply it; observations live outside this repo.
A reproducible workstation is a maintained explanation of how your working environment is built. The machine is an output of this repository, never an input: whatever makes the machine useful only counts as reproducible once it exists here as a reviewed declaration plus code that converges reality toward it. The ideas this repo is built on:
- One owner per kind of state. Files, system packages, developer tools,
and container packaging each have exactly one owner with a reviewable
declaration. Duplicate owners mean
command -vsucceeds while the shell runs an unintended version. - Declarations, not observations. A machine inventory is evidence, not intent. Only curated content in this repo states what the workstation should be; everything else — project source, browser sessions, credentials, model weights — has other owners and stays out by design.
- Convergence over installation. Every module is idempotent with a read-only check mode, so re-applying the declarations repairs managed state; "installed once" is not the goal.
- Keep the claim smaller than the evidence. This repo aims to be recoverable, convergent, and repeatable. Bit-for-bit reproducibility is a different, more expensive claim — a Docker digest identifies an artifact, it does not prove you can recreate it.
- Drift is a work queue, not a failure to hide. An out-of-band change is
detected by
scripts/drift.shand resolved deliberately — fold it into the declaration or revert it — so machine and repo never quietly disagree. - Update deliberately, recover from a runbook. Version bumps follow the
recipe recorded in the declaration and re-verify provenance; clean-machine
recovery is written down in
docs/runbooks/, not reenacted from memory.
| State | Owner | Declaration | Status |
|---|---|---|---|
| Ubuntu packages | apt module | packages/apt/*.txt |
active |
| Third-party apps & binaries (per-user) | apps module | packages/apps/*.txt |
active |
| Personal files (shell, git config, systemd units, mise config copies, OpenCode skills) | chezmoi | home/ |
active |
| GNOME desktop settings | desktop module | packages/desktop/*.txt |
active |
| Language runtimes / dev CLIs | mise | toolchains/mise.toml + lock |
active |
| Container packaging | Dockerfile (same modules + declarations) | docker/ |
active |
Project source (~/ws/<project>) |
each project's own git remote | never stored here | excluded |
Do not install the same tool through two owners. The package owner and the
effective executable must agree. In particular, bootstrap tools (chezmoi, mise)
stay out of mise itself — they are pinned below and installed by hand, because
they run the convergence and cannot be converged by it. (The mise registry entry
opencode is the older 1.x product line and must not be used; see
packages/apps/opencode.txt.)
Recovery means rebuilding from declarations; some state deliberately has other owners. This repo never stores, inventories, or pushes it:
- Project checkouts under
~/ws/— work lives in~/ws/<project>and each project is owned by its own git remote; recovery is cloning from there. Project lists, private remote URLs, and project content must not appear in this repository (it is pushed); that inventory belongs to your backup/remote system, not the workstation declaration. The only thing under~/ws/that this repo is about is its own checkout. - Credentials and identity — SSH keys, tokens, and git identity come from separately recovered machine data (chezmoi config) or a credential system; templates reference them, files never hardcode them.
- Observations — drift reports and package inventories live under
~/.local/state/workstation/: evidence, not declarations.
Drift checks and chezmoi only ever look at declared paths, so a clean
drift.sh result says nothing — and leaks nothing — about ~/ws/.
Both installer scripts verify the artifact hash themselves, but the script comes over TLS; review before piping.
sh -c "$(curl -fsSL get.chezmoi.io)" -b ~/.local/bin -t v2.73.1 # chezmoi
curl -fsSL https://mise.run | MISE_VERSION=v2026.10.8 sh # mise./bootstrap refuses to run without them and prints these commands.
./bootstrap # apt → apps → dotfiles → desktop → toolchains → hooks
./scripts/drift.sh # read-only: does reality still match the declarations?Every commit passes a leak gate (scripts/secret-scan.sh --staged, wired as
a pre-commit hook by bootstrap): no credentials, no operator identity, no
project references — the repo describes programs and installable
environments only. Audit the whole tree with ./scripts/secret-scan.sh --all.
scripts/drift.sh runs a workstation-drift.timer daily at 06:30 (systemd user
timer, installed via chezmoi; systemctl --user list-timers to inspect, reports
in ~/.local/state/workstation/drift/). The desktop layer check skips (not
fails) where no session D-Bus exists.
Bootstrap takes machine data from the environment (with sensible fallbacks):
WORKSTATION_PROFILE (desktop/server/container), WORKSTATION_GIT_NAME,
WORKSTATION_GIT_EMAIL. If chezmoi apply refuses because a managed file was
edited outside the repo, review with chezmoi diff, then either fold the change
into home/ or rerun with WORKSTATION_APPLY_FORCE=1.
Single layers:
sudo bash modules/apt.sh base # ensure apt packages are present
bash modules/apps.sh chrome # converge one app/CLI
WORKSTATION_APPS_CHECK=1 bash modules/apps.sh chrome # read-only check
chezmoi apply # re-project home files
bash modules/desktop.sh gnome # converge GNOME settings
(cd ~ && MISE_LOCKED=1 mise install --locked) # install pinned toolchainmodules/apt.sh is presence-only by design: it installs declared packages, it does
not remove, downgrade, or upgrade (the host security policy owns upgrades).
Groups may bind third-party apt repositories with repo = <name> lines; the
repo itself is declared by packages/apt/repos/<name>.sources +
<name>.key.asc (committed key, fixed keyring path) and the module converges
both before touching packages — tailscale is the reference group.
Mise ownership rule: the host's ~/.config/mise/config.toml and mise.lock
are chezmoi-projected copies of toolchains/. Never run bare mise use/mise install against them — mise then rewrites the projected lock and chezmoi (rightly)
reports drift. Edit toolchains/mise.toml, run mise lock there, chezmoi apply,
then install with --locked. Rust is intentionally absent until needed.
Large archives are not stored in Git. They live in a private cache:
~/.cache/workstation/apps/<archive name>
Each declaration in packages/apps/ pins the archive by name and sha256 (zip and
tar.gz/tgz are supported). The module verifies the hash before installing and
refuses to install on mismatch. Every declared app ends convergence with its own
declared smoke_cmd executed through the real launcher ($APP/$VERSION are
provided), so "converged" means functional evidence, not "a script ran".
- chrome: Chrome for Testing publishes versioned, permanent URLs but no checksum sidecar; the pinned sha256 is a documented local recovery anchor, not independent publisher verification. Update procedure in the declaration.
- opencode: v2 ships per-platform binaries as
@opencode/cli-<platform>npm packages; the registry publishes a per-tarballdist.integrity(sha512), which was cross-checked when pinning. Update procedure in the declaration.
Sources that rotate or delete artifacts (e.g. the Chromium snapshot bucket) cannot be re-fetched later; prefer publishers with versioned URLs and published hashes.
Ubuntu restricts unprivileged user-namespace creation: unconfined binaries that
create a userns are transitioned into a capability-less unprivileged_userns
profile and fail to write uid mappings. Chromium's legacy SUID sandbox helper no
longer exists in current builds. The supported fix is an AppArmor profile granting
userns to the exact executable path — same mechanism as Ubuntu's own
/etc/apparmor.d/chrome.
modules/apps.sh generates the profile to
~/.cache/workstation/apps/apparmor/workstation-chrome and check mode verifies the
installed /etc/apparmor.d/workstation-chrome matches it byte for byte. Clean
machine recovery: ./bootstrap, apply the printed AppArmor commands, rerun
./bootstrap.
The desktop layer converges exact values (gsettings get GVariant literals).
packages/desktop/gnome.txt was seeded from observed state on 2026-10-13 and is
the one place to change desktop preferences: edits made through the Settings app
or gsettings by hand are drift and will be reverted by the next ./bootstrap.
Run ./scripts/drift.sh first to see what would be reverted.
docker/ assembles sandbox images from the same declarations and modules
the host converges (modules/apt.sh, modules/apps.sh, toolchains/,
packages/apps/claude.txt, packages/apps/opencode.txt). Base images are
pinned by digest in docker/inputs.txt; image builds fetch app artifacts with
WORKSTATION_APP_FETCH=1 (opt-in, always sha256-verified against the
declaration). Deliberate host/image differences are documented in
docker/README.md.
bash docker/build.sh agent # build workstation-agent:local
bash docker/acceptance.sh # parity checks against the declarations
bash docker/run.sh # sandbox with $PWD mounted at /workspacesThe host engine itself is the apt group packages/apt/docker.txt (installed by
./bootstrap; afterwards sudo usermod -aG docker $USER and re-login).
~/.config/opencode/opencode.jsonandservice.jsonare intentionally not managed: the config contains host/LAN addresses that must not enter the (pushed) repository, andservice.jsonis OpenCode's own service-registration runtime state. Only the skills tree under~/.config/opencode/skills/is declared (viahome/); machine-local OpenCode config stays machine-local.~/.opencode/(old curl-installer copy and PATH entry) is no longer used — the managed launcher is~/.local/bin/opencode. Delete~/.opencodeonce no running process holds it (this session included).- The 158.0.8104.0 Chromium install is retained under
~/.local/apps/chrome/as the rollback anchor; the declaration comments carry the four rollback keys.