`.config/tend.yaml`'s sandbox probe asserted four of the five tools the agent needs. `nix` — installed by `tend-setup` for the weekly `flake.lock` refresh — sat outside it, so a regression in that install would have surfaced only once `tend-weekly` reached for `nix flake update` and found nothing. That gap is not hypothetical: PR #3428 (`bump MSRV and toolchain to 1.96`) was the bot, and it updated `Cargo.toml`, `rust-toolchain.toml` and `tests/helpers/wt-perf/Cargo.toml` while leaving `flake.lock` stale, because `nix` wasn't there. `nix` reaches the sandbox by the route `nu` does: `install-nix-action`'s multi-user daemon install puts `/nix/var/nix/profiles/default/bin` on `$GITHUB_PATH`, a system path the sandbox PATH derivation carries across verbatim. So it needs no `sandbox_setup:` line of its own, only the probe entry. The error message now points at `.github/CLAUDE.md`'s "Sandbox toolchain" section, since the old "wt hook pre-merge cannot run" is false for a weekly-only tool. The probe entry couples the install to every workflow: only `tend-weekly` runs `nix`, but the probe runs everywhere, so the step can no longer be gated to one. The `Install Nix` comment records that, in place of the cost argument that used to carry the question. Also here: `.github/CLAUDE.md` stated the sandbox PATH rule as existence-only, where tend additionally requires the sandbox UID to traverse the directory — the test that decides whether a root-owned `/nix` carries across, and so the one a reader of that section will want. <details> <summary>Why the install isn't cached, and why it isn't gated to <code>tend-weekly</code></summary> Measured from `##[end-action … duration_ms]` markers in four real tend run logs. `Install Nix` takes **3.46 / 3.97 / 4.13 / 4.24 s** against **~46–50 s** for the whole `tend-setup` composite, of which the `rust-cache` restore alone is 26.0–28.5 s and apt (zsh, fish) is 10.9–12.9 s. Within those 4 s, roughly half is downloading and unpacking the 25.8 MiB tarball and the rest is creating the nixbld users, `/etc/nix`, the store, and the `nix-daemon` systemd unit. That second half is root-level system state no restored `/nix` reproduces, so the install itself is not cacheable. Caching the *store* on top would serve only the weekly job's flake fetches while costing a restore in every workflow. The multi-user daemon install is also what makes `nix` reach the sandbox at all, so the faster single-user installers are not substitutes. `nixbuild/nix-quick-install-action` (~1 s) does `sudo install -d -o "$USER" /nix` and puts only `$HOME/.nix-profile/bin` on `$GITHUB_PATH` — which the `/home/runner` → `/home/tend-sandbox` rewrite drops, so `nix` would leave the sandbox PATH entirely. Self-installation by the agent was considered and rejected. The sandbox user has no sudo, and the official Nix binary hardcodes its ELF interpreter to `/nix/store/…-glibc-2.42-67/lib/ld-linux-x86-64.so.2`, so it cannot run without a root-created `/nix`. A statically linked `nix` does work for these two commands with no root — verified, using the automatic `~/.local/share/nix/root` chroot store — but no maintained build is published (`NixOS/nix` has no GitHub releases; `releases.nixos.org` serves no static variant; Hydra's `buildStatic` download URL now redirects to the manual), and a chroot store cannot build anything. Gating the install to `tend-weekly` was also rejected. It saves 4 s of a ~46 s setup on a public repo with free Actions minutes, `tend-ci-fix` watches `nightly` (which hosts the `nix-flake` job) and would lose the ability to reproduce a red nix build, and — because `sandbox_setup:` is top-level only in tend's config and cannot see which workflow it is in — gating would force dropping the probe entry that this PR adds. max-sixty/tend#1057 removes that last constraint for future cases. </details> ## Testing The probe loop was exercised both ways locally under `bash -eo pipefail`: exit 0 with all five names resolvable, exit 1 naming the first missing one otherwise. `uvx tend@latest init` (0.1.18, matching the pinned action) regenerates all eight workflows with exactly the one changed line each, and re-running it is a no-op. `pre-commit run --all-files` and `cargo test --test integration test_docs_are_in_sync` pass. The probe line itself can only run after merge: the `tend` environment admits only `main`, so `workflow_dispatch` from this branch is refused, and `tend-review` here runs under `pull_request_target` against the base ref, so it exercises `main`'s four-tool probe. The premise underneath it is verified, though — this PR's own `tend-review` session runs as `tend-sandbox` with `main`'s `tend-setup` behind it, which already installs Nix, so it checked the route directly: `command -v nix` resolves to `/nix/var/nix/profiles/default/bin/nix` (2.35.2), `nix flake --help` works with `experimental-features = nix-command flakes` inherited from the system-wide `/etc/nix/nix.conf`, and `nix store info` reports `Store URL: daemon`. So the added name cannot turn the eight workflows red, and the weekly recipe's two commands work from the sandbox rather than just its binary being present. > _This was written by Claude Code on behalf of max-sixty_ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.8 KiB
CI Automation — Worktrunk
See tend's security model for the generic security model. This file documents worktrunk-specific configuration.
Bot identity
worktrunk-bot — a regular GitHub user account (PAT-based, not a GitHub App).
Workflows check user.login == 'worktrunk-bot' directly.
Tokens
| Token | Purpose | Stored in |
|---|---|---|
TEND_BOT_TOKEN |
Every workflow acting as worktrunk-bot, including the winget and Homebrew publish jobs |
tend and release environments |
CLAUDE_CODE_OAUTH_TOKEN |
Authenticates Claude Code to the Anthropic API | tend environment |
CODECOV_TOKEN |
Uploads coverage from ci.yaml and coverage.yaml |
repo level, allowlisted in .config/tend.yaml |
Merge restriction
Only the repo owner (@max-sixty, admin) can merge to main.
worktrunk-bot has write role only. Enforced by a "Merge access" ruleset
(restrict updates, admin bypass in exempt mode). Required status checks:
test (linux), test (macos), test (windows), fast-checks.
Environment protection
Every secret except CODECOV_TOKEN lives in a GitHub Environment, where no
other workflow the repo runs can read it. A coverage upload grants nothing
worth gating. Each environment holds what one phase needs, since a job that
joins an environment can read every secret in it:
| Environment | Admits | Secret | Read by |
|---|---|---|---|
tend |
main |
CLAUDE_CODE_OAUTH_TOKEN, TEND_BOT_TOKEN |
every tend-*.yaml job except relay; append-gist; both create-issue-on-*-failure jobs |
release |
any tag | AUR_SSH_PRIVATE_KEY, TEND_BOT_TOKEN |
publish-aur, publish-winget, publish-homebrew |
signing |
any tag | SIGNPATH_API_TOKEN |
build-local-artifacts |
github-pages |
main |
none — OIDC only | deploy-docs |
The deployment branch policy is the gate: a job naming an environment runs
only from a ref the policy admits, so a workflow pushed to a feature branch is
refused before its first step. worktrunk-bot can push branches but cannot
update main (the "Merge access" ruleset) or move tags (the "Tag operations"
ruleset, admin-only), which is what makes both admitted sets unreachable to
it. No environment has a reviewer rule, so joining a job to one costs no
approval step.
The tag policies admit every tag rather than a v* pattern. "Tag operations"
covers ~ALL tags, so the pattern carries no part of the gate — it only
duplicates release.yaml's own tag filter, and the two are easy to drift
apart. They already had: the workflow fires on **[0-9]+.[0-9]+.[0-9]+*,
which matches an unprefixed 1.2.3 that a v* policy would then refuse. A
release cut under that name would have stopped at build-local-artifacts,
which names signing and waits only on plan — before an artifact was built,
let alone published.
TEND_BOT_TOKEN is stored in two environments rather than shared, because a
policy admits branches or tags but the token is needed under both: the bot's
own workflows run on main, the publish jobs on a tag. Adding the tag to
tend is not an option — tend check pins that policy to exactly the
protected branches and its --fix deletes anything else.
Jobs name tend as {name: tend, deployment: false}. GitHub files a
deployment record for every job that names an environment, against whatever
ref the run belongs to — under pull_request_target that is the pull request
itself, so an omission posts a "worktrunk-bot deployed to tend" line on every
push to every PR. deployment: false drops the record and keeps the gate. The
release jobs keep their records: a tag-push deployment lands in no PR
timeline, and it reads as what it is.
The generated tend-*.yaml files carry the same {name: tend, deployment: false}, written by tend's generator rather than edited here — every uvx tend@latest init overwrites them, so a hand edit would not survive one. tend
0.1.14 added the field and #3749 landed the regen, which is what cleared tend check's environment-deployments.
crates.io publishing holds no stored token — it uses Trusted Publishing.
crates.io mints a short-lived one only for an OIDC claim from release.yaml
running in that same release environment.
Sandbox toolchain
The agent runs as tend-sandbox, whose PATH tend derives from the runner's by
rewriting a leading /home/runner to /home/tend-sandbox and keeping an entry
only where the rewritten directory exists and the sandbox UID can traverse it.
useradd -m seeds the sandbox home from /etc/skel, so an image-baked
toolchain has a sibling there and survives; anything tend-setup installs at
runtime into the runner's home has none and is dropped, unlogged. So a tool the
agent needs has to land in a system location (/opt/hostedtoolcache/...,
/nix/var/nix/profiles/default/bin), which carries across verbatim — the route
nu and nix take — or be copied in by .config/tend.yaml's sandbox_setup:,
which is what the pre-merge gate's cargo-insta, cargo-nextest and
pre-commit take. Its closing probe asserts every tool on both routes.
Build environment
Swatinem/rust-cache hashes CARGO* and RUST* env vars into the cache key.
All workflows sharing a cache must set the same env vars, or they'll get
different keys and miss each other's caches.
It hashes the vars visible at its own step, so a var exported by a later
step is invisible and a var the writers don't set poisons the key. ci.yaml and
nightly.yaml carry theirs in a workflow-level env: block, always in place
first; the generated tend-*.yaml files can't, so tend-setup sets the same
three vars in a step above its cache step. A miss is silent — the step
succeeds having restored nothing — so drift here shows up only as slow jobs.