Contributing

New to the repo? Mission is what SPORE is actually for and the decision test every change is weighed against — read that first, before any feature idea. Dev guide is the map of where everything lives and which doc answers which question. This doc is the rules once you know where you're going: freeze, CI gates, branches, releases.

The SPORE v1 wire format and the crate's public API shape are frozen. The crate and the shipped distribution are versioned separately and are still 0.x — early, and honest about it. Freezing the wire at v1 while the software is at 0.x is not a contradiction: the protocol is what peers and reimplementations depend on, and it does not move. The rules below exist so that stays true — no release can silently break a peer or a downstream build.

Branches

The checks a PR must pass

  1. CI green on every leg — build, cargo fmt --check, cargo clippy (with -D warnings), tests on Linux/macOS/Windows, the wasm32 build with its single import, and the web / fountain / reference-decoder tests. See .github/workflows/ci.yml.
  2. API-freeze guard (.github/workflows/pr-guard.yml) — the PR must not modify the frozen contract files (below). This is what makes "1.0 won't break" a mechanical guarantee, not a promise.

Branch protection requires both, plus a Code Owners review, before merge.

The frozen v1 contract

These files define the compatibility surface and may not change while the wire format is v1 — regardless of what the crate's own version number says:

pr-guard.yml's regex is the ground truth; this table mirrors it in full. It listed five of the eight patterns for a while, which is worse than listing none — a contributor who checks the table, finds their file absent, and is then refused by CI has been told two different things by the same repo.

File Freezes
tests/** the public API shape + the golden wire/crypto vectors
reference/vectors.json the cross-language test vectors (generated)
reference/test_t0.py the conformance check the Tier-0 decoder must keep passing
bindings/spore.h the C ABI symbols
examples/gen_vectors.rs the generator those vectors come from
examples/worked.rs the worked bytes REBUILD.md is checked against
site/seed/*.test.mjs the paper-seed tooling's own conformance tests
web/test.mjs, web/ws-test.mjs the browser node's end-to-end contract
.github/workflows/{ci,pr-guard}.yml the guards themselves

Adding a test to a frozen test file still trips the guard. That is the guard working as designed — it is mechanical precisely so it cannot be argued with — and the label is the intended way through. Say in the PR that the change is additive and names nothing existing, so a reviewer can confirm it in one look rather than diffing a file they assume is untouchable.

Everything the wire format touches — envelope layout, address/ID derivation, signing, armor, sealed boxes, encrypted topics — is pinned by tests/api_freeze.rs as concrete bytes. A code change that alters any of them fails that test.

If you must change the contract (only for a 2.0), add the allow-frozen-change label to the PR to bypass the guard; the diff will be plainly visible as the breaking change it is.

Docs can't drift from code

Concrete documented values live once, in reference/vectors.json, generated by cargo run --example gen_vectors. CI regenerates it and fails on any drift, then scripts/check_docs_sync.py asserts that docs/REBUILD.md and the frozen test still reproduce those exact values. So if the code changes a byte, the docs and the frozen test must be updated to match or CI is red. Add new documented values to the generator and the sync check, not by hand.

Running everything locally

cargo fmt --all --check
cargo clippy --all-targets           # with RUSTFLAGS="-D warnings"
cargo test --all-targets
cargo run --example gen_vectors > reference/vectors.json && git diff --exit-code reference/vectors.json
python3 reference/test_t0.py
python3 scripts/check_docs_sync.py
cargo build --release --lib --target wasm32-unknown-unknown && node web/test.mjs
( cd site && npm install && node seed/fountain.test.mjs && node seed/seedsheet.test.mjs )
( cd site && node build.mjs )        # also fails on a broken internal link or anchor
python3 bindings/generate.py && git diff --exit-code bindings/
python3 design/generate.py  && git diff --exit-code android

The last two are the "bindings in sync" and "design tokens in sync" gates in supply-chain.yml: both directories are generated, so hand-editing one is a red PR rather than a merge conflict. They are listed here because omitting them was exactly the drift this section forbids.

Cutting a release

Actions → "release bump" → Run workflow → minor or major. That is the whole procedure. The workflow reads Cargo.toml, computes the next version (major resets minor to zero; the patch is always 0, because rolling builds generate their own from the merge time), retitles the CHANGELOG's ## Unreleased heading, and opens a PR. Merge it and the release publishes itself.

Two things it refuses to do, both deliberately:

Before pressing it, make sure "Running everything locally" passes on master, and check the register's Still open section: anything there a user would reasonably assume is closed belongs in the CHANGELOG, not only in the register. Shipping a release that oversells is the failure mode this project cares about.

Afterwards, confirm the release actually serves bytes — curl -fsI https://github.com/sloev/spore/releases/latest/download/spore-android.apk.

Why it is a PR and a merge rather than one button

Two GitHub behaviours make the obvious version silently useless, and ignoring either produces a green job that did nothing:

  • master is protected, so GITHUB_TOKEN cannot push the bump commit to it. Hence a PR — which is also a reviewable diff, which is what "a human bumps the version" ought to mean.
  • A tag pushed by GITHUB_TOKEN does not trigger workflows. GitHub blocks that to prevent recursion. So "create the tag and let the tags: trigger build it" would publish nothing. Instead android.yml — which already runs on every push to master — notices that Cargo.toml names a version with no tag behind it and calls gh release create, which makes the tag and the release together, in the job that is already running.

A single-button version needs a PAT or GitHub App token that can push to a protected branch and trigger workflows: a long-lived credential with write access to master. The two-click flow avoids it.

Manual tagging still works — v* and V* both match, and the build checks the tag against Cargo.toml — but it is the path that produced S-025: three hand-cut tags, none of which built anything.

Rolling and nightly builds need none of this. Every merge to master publishes <major>.<minor>.<YYYYMMDDHHMM>+<short sha> — patch is the merge time, build metadata is the commit — replacing the rolling release and writing a dated nightly-<date>, of which the last five are kept. major.minor is read from Cargo.toml, and no part of a version is derived from a git tag; that mistake produced a build called "SPORE rolling rolling+2026.07.27".