Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Contributing

Prerequisites

Workflow

Run just to list recipes. Before opening a pull request, run just check (the same gate as CI: fmt, clippy with warnings as errors, tests, shellcheck, advisories, dependency age), and just docs check if a doc changed. Keep PLAN.md in step with the work it tracks. For a change that may affect speed, run just bench (timings of the audit log’s reads on synthetic logs) before and after, and set SA_FORGE_SIGNER_LOG=info (see Configuration) to see where a real sign spends its time.

To try the MCP route from a Claude Code session in this checkout, .mcp.json adds a server named sa-forge-signer-debug: the debug build of sa-forge-signer-mcp, pointed at 127.0.0.1:18790. Build with just mcp-build, then leave just mcp-dev running in another terminal: it starts the debug sa-forge-signer-serve on a throwaway home in target/dev (its own config, an unfunded session key named dev, a token and an audit log), creating it on first run. Restart the Claude session after a rebuild; delete target/dev to start over. It is for this checkout only, and never for a real key: the session runs as the user who owns that home, so nothing there is isolated from it.

Code style

The tools enforce most of it: cargo fmt, and clippy with pedantic and nursery denied and the panic rules in Cargo.toml’s [lints] (no unwrap, expect, panic!, indexing or slicing outside tests, no as casts, checked arithmetic, unsafe_code forbidden). The rest is by convention:

  • Errors: return anyhow::Result; add context to every file, network and parse error (.with_context(|| format!("reading {}", path.display()))) so a message names what failed.
  • Lints: fix the code, for example split a function that is too long, rather than silencing the lint. Where that would make the code worse, or the lint is a false positive or fires on generated code, use #[expect(lint, reason = "...")] on the one item, never a bare #[allow].
  • Control flow: match or let ... else instead of checking and then unwrapping; a plain for loop when an iterator chain would be harder to read; an enum instead of a bool parameter whose meaning is not obvious at the call site.
  • Names: whole words, not abbreviations, except the usual short ones (cfg, tx, ix).
  • Modules: each module’s //! comment says what it owns and is the authority if the code map drifts. Code that mirrors other code (as the console mirrors the audit log) says so and has a test against real output.
  • Comments: short, for what the code cannot say: why, an invariant, or a dated observation.
  • Tests: for every check and every piece of logic; a refusal is tested as well as the pass.

For further reading, Google’s Rust style guide and the Rust API Guidelines are close to this, with two differences: we deny expect as well as unwrap outside tests, and the public interface is the one under Versioning, not a library API.

Commits

Use Conventional Commits: prefix each message with its type, write the rest as a sentence, and end it with a full stop.

  • feat: a new capability (a check, a command, a config key, a route)
  • fix: a bug fix
  • docs:, refactor:, test:, build:, ci:, chore: for the rest

Mark a breaking change with ! (feat!:, fix!:) and say what breaks in the body. For example: fix!: refuse a sign whose blockhash is already past the finalized height.

Versioning

The crate follows Semantic Versioning. Its public interface is what a caller or operator depends on, so a change to any of these is breaking:

  • the CLI: commands, flags, and their defaults
  • the JSON report: field names, types, and outcome values
  • exit codes
  • config.toml: keys, their meaning, and their defaults
  • the payload format the signer accepts
  • serve: HTTP routes, MCP tools, and auth
  • the audit log format

So is a check that refuses what was signed before. Adding an optional field, key, or command is not breaking.

While the version is 0.x, a breaking change bumps the minor version (0.4.0 to 0.5.0) and anything else bumps the patch version.

Releases

Releases are made by release-plz from the commit messages. The crate is not published to crates.io (publish = false); release-plz runs in git-only mode, reading the current version from the vX.Y.Z tags. On each push to main, it opens or updates a release PR that bumps the version in Cargo.toml and updates CHANGELOG.md. Review both in that PR; merging it tags vX.Y.Z and creates a GitHub Release. The Release binaries workflow then builds the binaries for that tag, attests them, and attaches them with a SHA256SUMS file to the release. To build them for an existing tag (or rebuild them), run it by hand from the Actions tab with the tag.

Install a release from its tag:

cargo install --locked --git https://github.com/ttdonovan/sa-forge-signer --tag vX.Y.Z sa-forge-signer