Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# AGENTS.md

The guidance for this repository is in **[CLAUDE.md](CLAUDE.md)** — the gate
(`mix precommit`), the test patterns, the release flow, and the things not to
do. It applies to any coding agent, not only Claude Code; this file exists so
agents that look for `AGENTS.md` find their way there.

[README.md](README.md) is the normative description of what `managoat_runner` does.
[CONTRIBUTING.md](CONTRIBUTING.md) covers licensing, the DCO sign-off, and what
a pull request is expected to carry.
197 changes: 197 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# CLAUDE.md — Managoat.Runner

Read by Claude Code and other AI coding tools when a session starts in this
repository. Keep it accurate; stale guidance misleads every session that reads
it.

[README.md](README.md) is the normative description of *what this library
does* — the contract, the pieces, the semantics a consumer can rely on. Read it
first. This file is about working *in* the repository: the gate, the tests, the
release, and the traps.

## What this repository is

> The self-hosted runner wire protocol: a WebSock connection process and a
> Managoat.Sandbox adapter over it, behind a host behaviour.

[`managoat_runner`](https://hex.pm/packages/managoat_runner) on hex,
`Managoat.Runner` in the code, Apache-2.0 throughout. It was extracted from
[Fountain](https://github.com/BinaryBourbon/fountain) under that project's ADR
0037 (component libraries, extracted umbrella-first under the `Managoat`
namespace) and graduated to this repository in BinaryBourbon/fountain#1345.
[NOTICE](NOTICE) records the lineage.

Fountain is now one consumer among others, pinned to a hex release like any
other dependency. The half of that which gets forgotten: **nothing here may
depend on Fountain, read its configuration, or assume its supervision tree.**
What the library needs from its host it takes as an argument, a behaviour, or a
configuration key with no default — so a consumer who names nothing gets an
error, not a silent default that happens to suit Fountain.

## Quick start

```bash
mise install # Erlang/OTP 28.3 + Elixir 1.19.2, from .tool-versions
mix deps.get
mix test # 9 test files
mix precommit # the whole CI gate, locally
```

`.tool-versions` is asdf format, so `asdf install` works too. The pins are not
advisory: CI runs exactly these versions, and the dialyzer PLT cache is keyed
on them.

## Repo layout

```
lib/ 8 modules — README.md is the guide to them
test/ 9 test files, mirroring lib/
scripts/release.exs the facts about a release, shared by the PR gate
and the publish workflow (no dependencies: it runs
before `mix deps.get` in both)
.github/workflows/ ci.yml the gate
release-gate.yml does this PR need a version bump?
publish.yml merging a bump ships it to hex
```

Test doubles this library ships for its *consumers* — they are part of the
package, not of this suite: `Managoat.Runner.FakeDaemon`, a daemon that speaks
the wire protocol from inside the BEAM.

## The gate

`mix precommit` runs what `.github/workflows/ci.yml` runs, in the same order:

| Step | Why |
|---|---|
| `deps.unlock --unused` | an unused lockfile entry is a dependency someone removed and half-committed. Checked by diffing the lockfile, because the bare task rewrites it and exits 0 |
| `format --check-formatted` | |
| `compile --warnings-as-errors` | |
| `credo --strict` | config in `.credo.exs` |
| `dialyzer` | |
| `test --cover` | the coverage threshold gates here, not in a separate job |
| `hex.build` | what `mix hex.publish` will build. A dependency hex refuses — a git dep, a path dep — fails on the PR rather than on `main` |

`def cli` sets `preferred_envs: [precommit: :test]`, matching CI's job-level
`MIX_ENV: test`. Two steps override that back to `:dev` by shelling out, the
same override CI makes: dialyzer analyses the *shipped* code, so the test
environment would drag test-only dependencies into the analysis and into the
cached PLT, and `hex.build` builds the package as it will be published.

**The first `mix dialyzer` builds a PLT and takes minutes.** It lands in
`priv/plts/` — pinned there by `mix.exs` so CI can cache it across runs — and is
gitignored. Later runs take seconds.

Read the output rather than trusting the exit code, and confirm you reached
`N tests, 0 failures`.

## Coverage

`mix test --cover` gates at **97%**, configured in `mix.exs`.

What the suite measures on its own: the connection, the adapter, the names,
the two hosts and the fake daemon, driven end to end by `managoat_sandbox`'s
conformance suite against `Managoat.Runner.Host.Local`.

The threshold is a ratchet. Raise it as the suite grows; never lower it to turn
a red run green. If a change genuinely cannot be covered, say so in the PR — the
number is a claim about this library, and it is the one thing a consumer cannot
check for themselves.

## Test patterns

`test/test_helper.exs` sets up the two things this library refuses to default:

```elixir
Application.put_env(:managoat_runner, :host, Managoat.Runner.Host.Local)
{:ok, _} = Managoat.Runner.Host.Local.start_link()
```

A consumer that names no host gets an error rather than a registry that quietly
finds nothing, so the suite has to name one. It then registers the adapter into
`managoat_sandbox`'s adapter map beside the three that library ships, because
the tests that go through the facade (`host_path`) dispatch through it.

`adapter_conformance_test.exs` runs `Managoat.Sandbox.ConformanceCase` — the
suite that lives in `managoat_sandbox` — against the adapter over a fake daemon.
That is the real gate on this library: the adapter is only correct insofar as it
behaves like every other sandbox.

### The async config guardrail

`async_global_config_guardrail_test.exs` fails the suite if any `async: true`
test module writes `Application.put_env(:managoat_runner, ...)`.

Application environment is global and ExUnit runs async modules concurrently,
so a module that writes configuration changes it for every test running beside
it, for as long as the write is held. The failure then lands in a different
file, on some seeds only. A test that must write configuration goes in a
sibling `async: false` module; it does not become an async module with a
`put_env` in it.

## Releasing

The publish workflow ships whatever version lands on `main`, so **the version
bump is the release**. There is no tag to push and no button to remember.

1. Bump `@version` in `mix.exs`.
2. Add a `## [<version>]` heading to `CHANGELOG.md` saying what changed.
3. Merge. `publish.yml` publishes to hex and tags `v<version>`.

`release-gate.yml` runs `elixir scripts/release.exs guard` on every PR and fails
it when the PR changes what the package ships — `lib/`, `priv/`, or the
consumer-facing part of `mix.exs` — without a bump. Without that gate the change
sits on `main` unreleased, invisible until somebody wonders why the fix they
merged is not on hex.

The gate also front-loads what would otherwise fail the publish on `main`: a
version already on hex (hex never allows a version to be republished) and a
missing changelog heading.

The deliberate exception is the **`no-release`** label, which skips the gate.
It is for a change that touches those paths without altering what a consumer
gets. The PR that added this file is exactly that case: a `precommit` alias in
`mix.exs` is build tooling, and `scripts/release.exs` compares `mix.exs`
textually rather than semantically. Use the label rarely, and say why in the PR
body.

`scripts/release.exs state` prints the current facts as JSON, and is the same
code the gate and the publish workflow read, so the two can never disagree.

## Things NOT to do

- **Don't push directly to `main`.** Every change goes through a PR; `ci` and
`release gate` are both required checks.

- **Don't take a dependency on the host application.** Not on Fountain, not on
its configuration, not on its supervision tree. A configuration key this
library reads should have no default when the right default is the host's
business.

- **Don't lower the coverage threshold.** See above.

- **Don't let an offline, disconnected or timed-out request become anything
but `{:unavailable, _}`.** That tag is what marks an error transient in the
`Managoat.Sandbox` taxonomy, and a self-hosted runner is offline as a matter
of course rather than as a fault.

- **Don't describe unbuilt behaviour as existing.** In a moduledoc, the README
or the changelog, mark what is not yet built as not yet built, and remove the
caveat in the PR that builds it. A 2026-07 audit of the parent project found
three mechanisms asserted as implemented that did not exist; everyone reading
the docs concluded the system had properties it did not have.

- **Don't leave a `CHANGELOG.md` entry to the release.** Write it in the PR that
makes the change, under the version that ships it.

## Where the wider context lives

Architecturally significant decisions from before the split are ADRs in
Fountain's `decisions/` directory; 0037 is the one that created this repository.
This library is small enough that its own design rationale lives where it
applies — in moduledocs and in README.md — rather than in a decision log of its
own. If a choice here needs more than a moduledoc paragraph, that is the signal
to start one.

[CONTRIBUTING.md](CONTRIBUTING.md) covers licensing, the DCO sign-off, and what
a PR is expected to carry.
81 changes: 81 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Contributing to Managoat.Runner

Start with [CLAUDE.md](CLAUDE.md): the gate, the test patterns, the release
flow and the traps. [README.md](README.md) is the contract this library owes
its consumers.

## Licensing of contributions

This library is Apache-2.0, inbound and outbound. **You contribute under the
Apache License 2.0**, and that is what it is distributed under. There is no
separate document to sign and no CLA bot; opening a pull request is the grant.

You keep the copyright in your work. This is a license, not an assignment, and
Apache-2.0 does not restrict you, so you keep the full right to reuse your own
contribution anywhere else, including in proprietary code of your own.

One thing to know, stated plainly rather than left to be discovered: Apache-2.0
permits relicensing, and this library was extracted from
[Fountain](https://github.com/BinaryBourbon/fountain), which is not licensed as
a single unit. A contribution here can therefore be redistributed by Fountain's
maintainer under the AGPL, under the Elastic License, or under a commercial
license. That is the same asymmetry a CLA creates, with less ceremony. If it is
not a trade you want to make, say so on the pull request; it is a reasonable
thing to object to.

## Sign your commits (DCO)

This project uses the [Developer Certificate of
Origin](https://developercertificate.org/) — a one-line assertion that you wrote
the patch, or otherwise have the right to submit it. Your sign-off also records
your agreement to the inbound terms above. Add it with `-s`:

```bash
git commit -s -m "fix: ..."
```

That appends a `Signed-off-by:` trailer built from your `user.name` and
`user.email`. Note that `git config format.signOff true` does **not** do this
for `git commit`; use `-s`, or install a `commit-msg` hook.

## Before you push

```bash
mix precommit
```

That is the CI gate, in CI's order: the unused-dependency check, format,
compile with warnings as errors, `credo --strict`, dialyzer, the tests with
coverage, and the hex package build. Read the output rather than trusting the
exit code, and confirm you reached `N tests, 0 failures`.

The first run builds a dialyzer PLT and takes minutes. Later runs take seconds.

## Does your change need a release?

Probably, if it touches `lib/`. The publish workflow ships whatever
version lands on `main`, so the version bump *is* the release:

1. Bump `@version` in `mix.exs`.
2. Add a `## [<version>]` heading to `CHANGELOG.md` describing the change.

The `release gate` check enforces this on the PR, so it is caught during review
rather than after. `elixir scripts/release.exs guard origin/main` runs the same
check locally.

Versions follow [SemVer](https://semver.org/), and pre-1.0 a minor bump may
include breaking changes and says so. A version is never republished — hex
refuses it — so a bad release is fixed by another release.

For a change that touches those paths without altering what a consumer gets,
apply the **`no-release`** label and say why in the PR body.

## Pull requests

Every change goes through a pull request, and both checks — `ci` and
`release gate` — must pass. Nothing is pushed to `main` directly, and nothing is
published from a laptop.

A good PR body says what changed and why, and names the failure mode it
prevents when there is one. The commit history in this repository is written
that way; match it.
67 changes: 67 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Managoat.Runner.MixProject do
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
description:
"The self-hosted runner wire protocol: a WebSock connection process and a Managoat.Sandbox adapter over it, behind a host behaviour.",
package: package(),
Expand All @@ -27,6 +28,13 @@ defmodule Managoat.Runner.MixProject do
]
end

# `mix precommit` runs in :test so that its compile, credo and test steps see
# exactly what CI sees — .github/workflows/ci.yml sets MIX_ENV=test for the
# whole job. The two steps that must not run there shell out to :dev.
def cli do
[preferred_envs: [precommit: :test]]
end

def application do
[extra_applications: [:logger, :crypto]]
end
Expand Down Expand Up @@ -81,4 +89,63 @@ defmodule Managoat.Runner.MixProject do
plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
]
end

# The CI gate, in CI's order, in one command. Keep this and
# .github/workflows/ci.yml in step: a check that lives only in CI is a check
# every contributor discovers by pushing.
defp aliases do
[
precommit: [
&deps_unlock_unused_changes_nothing/1,
"format --check-formatted",
"compile --warnings-as-errors",
"credo --strict",
&dialyzer_in_dev/1,
"test --cover",
&hex_build_in_dev/1
]
]
end

# Parity with CI's `mix deps.unlock --unused && git diff --exit-code
# mix.lock`. A bare "deps.unlock --unused" step rewrites the lockfile and
# exits 0, so the alias would pass while CI failed the same commit on the
# diff. Compared against the file as it was a moment ago rather than against
# git HEAD, so an uncommitted but legitimate lockfile edit (a dependency
# added on this branch) does not trip it.
defp deps_unlock_unused_changes_nothing(_args) do
before = File.read!("mix.lock")
Mix.Task.run("deps.unlock", ["--unused"])

if File.read!("mix.lock") != before do
Mix.raise(
"mix.lock listed unused dependencies (deps.unlock --unused just pruned them). " <>
"Commit the updated mix.lock — CI fails this via `git diff --exit-code mix.lock`."
)
end

:ok
end

# MIX_ENV=dev on purpose, the same override CI makes: dialyzer analyzes the
# shipped code, and the test env would drag test-only dependencies into the
# analysis and into the cached PLT. A function rather than a "cmd ..." step
# because `mix cmd` execs without a shell and cannot set the environment.
defp dialyzer_in_dev(_args), do: mix_in_dev(["dialyzer"])

# What `mix hex.publish` will build, in the environment it builds it in: a
# dependency hex refuses (a git dep, a path dep) fails here rather than on
# the PR. The tarball goes to a temporary directory because it is a check,
# not an artifact, and nothing here should have to gitignore it.
defp hex_build_in_dev(_args) do
app = Atom.to_string(Mix.Project.config()[:app])
mix_in_dev(["hex.build", "--output", Path.join(System.tmp_dir!(), app <> "-precommit.tar")])
end

defp mix_in_dev(args) do
case Mix.shell().cmd(Enum.join(["mix" | args], " "), env: [{"MIX_ENV", "dev"}]) do
0 -> :ok
status -> Mix.raise("mix #{Enum.join(args, " ")} failed with exit status #{status}")
end
end
end