diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b7466b9 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cf6f0f5 --- /dev/null +++ b/CLAUDE.md @@ -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 `## []` heading to `CHANGELOG.md` saying what changed. +3. Merge. `publish.yml` publishes to hex and tags `v`. + +`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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3e3d4b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 `## []` 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. diff --git a/mix.exs b/mix.exs index c206c69..db79bea 100644 --- a/mix.exs +++ b/mix.exs @@ -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(), @@ -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 @@ -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