shep-deploy 0.2.2

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. Check `shep --version` before trusting a green integration run, or
point `SHEP_BIN` at a build of shep's `main`.

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
```

269 unit tests and 7 integration as of 2026-08-28, ~20s and ~31s. The number moves with every task; treat it as a shape, not a checksum.

## Architecture

Binary crate, no library target. Twenty modules under `src/`, the big three
being `deploy.rs` (the state machine), `optin.rs` (first cutover) and `poll.rs`
(the dog's loop).

`#[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.rs` has a production `run_git` and `deploy.rs` a production `head_of`,
both in scope in their own test modules.

`tests/integration.rs` is a separate compilation target and keeps its own copy
of those helpers. That is the cost of the boundary, not an oversight.

## 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.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`.
- **cleared environment** (`build.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.

`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.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 35 sites across ten modules.** It is not the
  `[dog.deploy]` variant its doc used to claim.
- **`retention` keeps `retention + 1` directories.** The live release is spared
  in addition to the newest N, deliberately. Rin's call, 2026-08-28: document
  it rather than change it.
- **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.
- **`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

Follows shep's `shep-idiomatic-rust` skill and `docs/idiomatic-rust.md`
(IR-1..IR-46). `#![forbid(unsafe_code)]` at `main.rs:33`, which is why
`build.rs` resolves users by shelling out to `id` rather than calling
`getpwnam`.