Files
max-sixty__worktrunk/.github/CLAUDE.md
T
Maximilian Roos 6b0c0d0f4c ci(tend): assert nix reaches the sandbox alongside the rest of the toolchain (#3909)
`.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>
2026-08-25 15:01:56 -07:00

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.