shep-deploy 0.3.13

A deploy dog for shep: watches a git branch, builds a release, swaps to it, and rolls back if it does not come up
# shep-deploy — CLAUDE.md

A deploy dog for [shep](https://github.com/shep-pm/shep). Watches a git
branch, builds a release in an isolated worktree, swaps a `current` symlink
with `rename(2)`, reloads the sheep, and rolls back if the new release does not
come up. Unix only, deliberately. MIT OR Apache-2.0.

Consumes `shep-core` and `shep-client` as **published crates**, never as a
path dependency. That is the point of the project, not an accident: it exists
partly to find out whether somebody outside shep's workspace can build a dog
from what shep publishes, and a path dependency would paper over anything
missing.

## Commands

### `cargo test --all-features` FAILS on a fresh clone, and that is correct

The integration tier needs a real shep binary and panics without one:

```text
the integration tier needs $SHEP_BIN pointing at a built shep binary,
for example SHEP_BIN="$(command -v shep)": NotPresent
```

So the local loop is one of these, not the shep-workspace habit of
`--all-features`:

```bash
cargo test
```

```bash
SHEP_BIN="$(command -v shep)" cargo test --all-features
```

**And `$SHEP_BIN` has a version floor, which a green local run will not tell
you about.** The integration tier asserts behaviour shep only has from 0.1.10:
a probed app reloads serially, and a replacement that never becomes ready is
left `starting` rather than `online`. Point `SHEP_BIN` at an older shep and
`a_release_that_cannot_come_up_is_rolled_back_and_the_old_release_serves`
passes for the wrong reason. That happened on 2026-08-28: the local tier was
green against an installed 0.1.8 while CI, which installs the current release,
failed.

The floor moves with `shep-client`, too, and in both directions. The lockfile
pins shep-client 0.10.0, which speaks protocol 9, so a shep that does not
accept 9 fails every integration test at connect with `protocol mismatch
(this client speaks 9)`. `shep --version` prints both halves of that
question, as `speaks protocol 9, accepts 8 and newer`.

The other direction bit on 2026-09-04: the lockfile spoke 2, shep 0.2.0
shipped speaking 3 within the hour, and CI, which installs the current
release, failed all seven tests at connect while the local tier was green
against a scratch 0.1.31. When that happens the fix is the dependency, not
the tests: bump `shep-client` in Cargo.toml and `cargo update -p
shep-client`.

**`cargo info shep-client` reports the newest version SEMVER-COMPATIBLE WITH
THE LOCKFILE and calls it "latest", so it under-reports by as many majors as
you are behind.** Ask by name, and keep asking until it stops moving. On
2026-09-08 a 0.2.0 lockfile made `cargo info` answer "latest 0.4.3"; taking
0.4.3 made the same command answer "latest 0.7.2", which was the real one.
Two rounds, and a caret bump to `"0.4.3"` would have quietly landed on 0.4.6
and looked deliberate. `cargo info shep-client@<version>` and
`cargo info shep` are the cross-checks.

shep 0.7.2 also raised MIN_SUPPORTED to 8, so this is a floor rather than a
preference: `shep --version` says `speaks protocol 8, accepts 8 and newer`,
and every shep-deploy built before 2026-09-08 is refused at the handshake by
it. Check `shep --version` before trusting either a green or a red
integration run. To test against the current release without touching the
machine's own shep, install one into a scratch root and point `SHEP_BIN` at
it:

```bash
cargo install shep --locked --force --root /tmp/shep-root
```

```bash
SHEP_BIN=/tmp/shep-root/bin/shep cargo test --features integration
```

CI already does it right: `cargo test --locked` for the units, then a separate
job that runs `cargo install shep --locked` and then
`cargo test --features integration`. That installs the PUBLISHED shep from
crates.io, not a git checkout, and the workflow says so in its own comment: a
red integration job is a signal against shep's released surface rather than
against whatever `main` looks like that day. This file claimed "from the tip
of `main`" until round 8 of the founder's review read the workflow. Only a human running the obvious command
hits this. Cost me a wrong "baseline is green" call on 2026-08-28.

### The gate

Each from its own command with `$?` read directly, never through a pipe. In zsh
a pipeline's `$?` is the last command's, so `cargo test ... | tail` reports
`tail`'s success while the suite is red. That exact mistake produced four
`EXIT=0` lines over a failing suite on 2026-08-28.

```bash
cargo fmt --all --check
```

```bash
cargo clippy --all-targets --all-features -- -D warnings
```

```bash
SHEP_BIN="$(command -v shep)" cargo test --all-features
```

```bash
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
```

405 unit tests, 7 integration and 3 probe as of 2026-09-22, ~24s, ~32s and instant. The number moves with every task; treat it as a shape, not a checksum.

## Architecture

Binary crate, no library target. Twenty-four modules under `src/`, the big three
being `deploy/` (the state machine, a directory), `optin/` (first cutover, a directory)
and `poll/` (the dog's loop, with `report.rs` for what it says). `route.rs` holds `Route`, `route` and `USAGE`, and `exit.rs`
the exit-code mapping; both left `main.rs` to keep it under 800 lines.

`#[cfg(test)] mod fixtures` is declared from `main.rs`, which is the only way a
binary crate with no lib target can share test helpers. Use
`fixtures::run_git`, `fixtures::head_of`, `fixtures::fixture_release`,
`fixtures::TEST_BUDGET`. Qualified rather than glob-imported on purpose:
`shared/process.rs` has a production `run_git` and `deploy/prepare.rs` a production `head_of`,
both in scope in their own test modules.

`tests/integration/` is a separate compilation target, one target of several
modules (`main.rs`, one module per group of tests, `support/` for the helpers),
and keeps its own copy of those helpers. That is the cost of the boundary, not
an oversight.

`tests/probe.rs` is the other one, and unlike the integration tier it is NOT
feature-gated and needs no shepherd: it spawns the built binary with
`--version` and `--schema`. Both questions are asked of a dog that has never
run and cannot connect to anything, so a test that wanted a live daemon would
be testing something else. It runs in a bare `cargo test`.

## The security surface, which is the whole point of reviewing this crate

A deploy runs a build command **from the repository being deployed**. Anyone who
can land a commit on the tracked branch chooses that command. Three bounds
exist and each has a precise edge:

- **uid/gid drop** (`build/mod.rs`), applied to the build's `Command`, so it bounds
  the CHILD. Anything the parent does afterwards on paths the child chose is
  outside it. A path-traversal in `build.artifacts` exploited exactly that gap
  on 2026-08-28 and reached the tree's own `deploy.toml`. A root dog first
  hands the release and `cache_target()` itself to `user` (`ownership.rs`),
  by descriptor, never by path, skipping symlinks, files with a second hard
  link and the root `.git` file, which it writes back after the build because
  owning the directory lets the build replace it; every other write root makes into a release must stay handle-based too.
- **cleared environment** (`build/env.rs`), so a build gets `BASE_ENV` plus the
  operator's `passthrough` plus the release's `[build] env`, and nothing else.
  `SSH_AUTH_SOCK` is excluded on purpose.
- **`user` on the app**, which is the only bound that also covers what the
  build can READ. It does not cover the project's own `.env`: gitignored files
  are symlinked into every release by design, so a build reads whatever the app
  reads.
- **The Flockfile read** (`flockfile/read.rs`) runs in the dog's own process, at its
  own uid, BEFORE any build drops to `user`. A committed `Flockfile.toml` that
  is a symlink is therefore refused unread (`O_NOFOLLOW`), and a parse error
  names the line number and never quotes the line: a link at
  `/root/.ssh/id_rsa` used to have the key's first line printed to the log
  through the parser's own message. Found 2026-09-03. The operator's override
  is the one file that IS followed, because `shared::link_into` made it a link.

`docs/specs/deferred.md` records Landlock as the thing that would close the
class rather than the instances.

## Gotchas

- **`deploy.toml` is hand-edited by operators.** `deploy/prepare.rs` tells them to type
  `verify = "alive"` into it. It denies unknown fields for that reason; keep it
  that way.
- **`Error::Config` is built at dozens of sites across ten modules.** It is
  not the dog-section variant its doc used to claim.
- **`main` answers `--version` and `--schema` before it does anything else.**
  `shep_client::dogs::probe::<config::Section>` is the first line, ahead of
  the runtime, and it exits the process when the run is a probe. Order is the
  whole point: `shep adopt` and `shep restart deploy` both spawn the binary
  with `--version`, and a dog that does not recognise the flag starts its
  ordinary poll loop against the live shepherd until shep kills its process
  group a second or two later. `route` never sees either flag, which is why
  neither has an arm there.
- **`config::Section` is the schema, so its field docs are operator-facing.**
  They are what `shep lookout`'s settings pane prints, not commentary for the
  next reader; the reasoning belongs on `config::DogConfig`. The type-level
  doc is overridden with `#[schemars(description = ...)]` for the same
  reason, since schemars would otherwise publish the Rust doc verbatim.
  `#[shep(secret)]` marks a credential, and this section has none. Since
  shep-pm/shep#594 the mark rides the field rather than naming it, so it
  reaches a renamed property and a nested one, and the old runtime failure
  it could produce is gone: `dogs::config_schema` no longer returns a
  `Result`.
- **shep-client is floored at 0.10.0 for `dogs::dog_config`, and at 0.7.3
  under that for its `schema` feature. Neither floor is a protocol
  number.** 0.10.0 replaced the `DogConfig` derive with the `dog_config`
  attribute (shep-pm/shep#594), and `dogs::probe` is bounded on the trait
  the attribute implements, so a dog cannot answer `--schema` without it.
  The attribute belongs ABOVE the derives, since it rewrites the fields it
  marks and `JsonSchema` expanding first would render them unmarked. It
  refuses the wrong order only when some field really carries the mark, so
  this dog's section, which marks nothing, compiles either way and the
  order is convention rather than something the compiler checks. Below
  that, 0.7.2's `schema` did not forward to `shep-core/schema`,
  so `UpDuration` had no `JsonSchema` impl and this dog's section had no
  schema to publish; the workaround was a direct shep-core dependency whose
  only job was turning that feature on. shep-pm/shep#198 fixed the forward
  and the dependency is gone. shep-core is reached through
  `shep_client::shep_core::` only, `tests/probe.rs` included; naming it at
  the crate root is what quietly makes that dependency load-bearing again.
- **The dog's section is `[deploy]` in `$SHEP_HOME/dogs.toml`**, since shep
  0.1.32; before that it was `[dog.deploy]` in `shep.toml`, and a shepherd
  migrates the old spelling only at boot. A test that writes the section
  after the shepherd is up has to write the new file, or the dog runs at its
  default interval. Cost the integration job on 2026-09-04.
- **`retention` keeps the newest N, and spares two more by name.** The release
  `current` points at and the one `deploy.toml` names are never removed,
  whatever their age. In the ordinary case the live release is the newest, so
  a target holds N directories; only after a deploy died between its swap and
  its record write can it hold up to N + 2. README.md and the config doc said
  "N besides the live one" until 2026-09-03; the code never did that.
- **`State::read` validates values, not only shape.** A sha has to be 40 (or
  64) hex characters, `remote` and `branch` cannot be empty or begin with `-`,
  `checkout` has to be absolute, and `origin_cwd`/`origin_script` come as a
  pair. A test fixture that writes a record with `deployed = "a1b2c3d"` is
  refused on read; use a full sha.
- **`optin::Prepared` carries the tree's `flock`.** It lives from `prepare`
  through `cut_over`. A test that calls `prepare` twice on one tree has to
  drop the first `Prepared` first, or the second is refused as
  `AlreadyDeploying`.
  The hold field is private to `optin/prepare.rs`; `cut_over` takes it
  through `Prepared::into_parts` and keeps it to its end.
- **Never derive the markers directory from `Tree::completion("")`.** The
  result has a trailing separator and its `parent()` is the tree root; a sweep
  written that way removed `current`. `Tree::completions()` is the directory.
- **`io::ErrorKind::FilesystemLoop` is unstable.** An `O_NOFOLLOW` open that
  meets a link answers `ELOOP`; test it with `shared::is_eloop`.
- **`deploy.toml` carries the pre-adoption `AppConfig` as `origin`.** It
  serialises as `[origin]` plus its sub-tables at the END of the file
  whatever the field's position, because this crate's `toml` hoists every
  scalar above every table. It holds `env` verbatim, so the file is written
  0o600. A shep-deploy older than 2026-09-04 refuses a record
  carrying the field at all (`deny_unknown_fields` on the record), which Rin
  accepted that day. `AppConfig` itself was `deny_unknown_fields` in
  shep-core until 0.7, and is NOT any more: shep-core dropped it on purpose
  and says in its own comment not to restore it. So a record or a muster
  roll written by a newer shep-core is now READ by an older reader, with the
  fields it does not know dropped in silence. See `roll.rs`'s own module doc
  for why that trade is the right way round for `survey` and the wrong way
  round for `optin`, which persists the result and replays it on removal.
- **A committed Flockfile is refused for more than `user`/`group`.** `exec`
  probes, `out_file`/`err_file`, non-loopback probe targets, and `watch`,
  because the daemon acts on all of them at its own uid (read out of
  shep-daemon on 2026-09-04; `flockfile::refuse::refuse_repo_privilege` says why per
  field). It is also refused `[dog.deploy.release]` and `[dog.deploy.after]`,
  because a hook's env holds the operator's secrets (`hook::refuse_repo_hooks`),
  and so is an override the dog did not link in from the operator's checkout. Presence is checked before shape: serde builds a probe from a
  TOML array too, and a walk that read only tables let one through.
- **`Online` is a one-time verdict in shep.** A crash after it respawns under
  a new pid with the reload committed; a failed post-drain re-probe demotes
  to `Starting` under the same pid. Both verify modes dwell and re-check pids
  and status; `deploy::dwell_for` lengthens the probed dwell to
  `listen_timeout` for a `reuse_port` app, whose re-probe can land that late.
- **`Error::Raced` is held like any other ending.** The tree lock makes a
  rival deploy impossible, so `current` moving under a deploy is a hand or
  the release's build command, and an unheld build that repoints `current`
  would be rebuilt every tick.
- **The Flockfile is parsed once per deploy, through `flockfile::read`.** The
  free `app_config` and `build_spec` are `#[cfg(test)]` wrappers; production
  code asks both questions of one `Merged`.
- **`shared::FromCheckout` is minted only by `shared::to_link`.** It is the
  list of paths linked in from the operator's checkout, and its
  `includes_override` answer is the whole proof that `Flockfile.override.toml`
  is the operator's file. The field is private and there is no `From` impl on
  purpose: a trait impl is reachable from every module, which would let any
  caller mint the proof. Tests build one with `FromCheckout::of`.
- **An artifact is copied through a per-component walk, not through a path.**
  `build::walk::Walk` opens every directory from the release or the build cache with
  `openat` and `O_NOFOLLOW | O_DIRECTORY` against the descriptor above it, so
  a parent swapped for a symlink mid-copy is refused rather than followed. A
  component that answers `ELOOP` is followed only when `readlinkat` says it
  names one of the two roots exactly, which is what keeps `link_cache`'s
  `release/target` working. `lands_within` still runs first and is what
  produces the message an operator reads. It uses `rustix`, not `nix`: nix
  0.29's `openat` returns a bare `RawFd` and this crate forbids unsafe.
- **A regression test for the artifact escape is worthless without
  `CARGO_TARGET_DIR` set.** Without it, source and destination collapse to the
  same path and the self-copy guard returns `Ok` before reaching anything the
  test is about. The test says so in its own doc; do not simplify it.
- **Two locks per tree, and the order is fixed.** `lock::hold` is the deploy
  lock, non-blocking, held for a whole build. `lock::hold_record` is the
  record lock, blocking, held for one read and one write of `deploy.toml`:
  `set_watch` and every whole-record write in `deploy` and `optin` take it.
  A deploy takes the tree lock first and the record lock inside; nothing
  holding the record lock waits for the tree's. The cutover's final write
  adopts `verify` from disk and deliberately not `watch`: `prepare` wrote
  `manual` and that write is the promotion to `auto`, so a `--watch manual`
  typed during setup is overwritten and belongs after setup returns.
- **A build's process group is led by a holder, not by the build.** `sh -c
  'read _'` on a pipe the dog holds, so the group id stays reserved after the
  build is reaped and `killpg` cannot land on a recycled pid. Never signal a
  pid after reaping it; `shared::abandon` kills before it waits for the same
  reason. It lives in `build::runner::run_in_group`, which takes a prepared
  `Command` so another caller can reuse it.
- **`git::fetch` is the only git call that touches a network**, and the only
  one using `run_git_within`. The other ten `run_git` callers are local.

## Style

Invoke the `rust-house-style` skill before writing or reviewing Rust: the
rules are shep-pm/rust-house-style, IR-1..IR-48. `#![forbid(unsafe_code)]`
at `main.rs:40`, which is why `build/privilege.rs` resolves users by shelling out to
`id` rather than calling `getpwnam`.