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
masteris protected and always green. It only changes through reviewed pull requests; nobody pushes to it directly.- Work happens on a topic branch off
master, named for what it does —feat/…,fix/…,docs/…,ci/…— and merges back intomaster, squashed. There is no long-lived integration branch:developdoes not exist, and every branch in the repo is a topic branch. (The CI workflows still namedevelopin theirpush:triggers, harmlessly, so one can be introduced without touching them.)
The checks a PR must pass
- CI green on every leg — build,
cargo fmt --check,cargo clippy(with-D warnings), tests on Linux/macOS/Windows, thewasm32build with its single import, and the web / fountain / reference-decoder tests. See.github/workflows/ci.yml. - 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:
- Release with an empty
## Unreleased. A release nobody can read about is worse than no release. Write entries as work merges — that is what the section is for. - Reuse an existing tag. It fails rather than clobbering.
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:
masteris protected, soGITHUB_TOKENcannot 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_TOKENdoes not trigger workflows. GitHub blocks that to prevent recursion. So "create the tag and let thetags:trigger build it" would publish nothing. Insteadandroid.yml— which already runs on every push tomaster— notices thatCargo.tomlnames a version with no tag behind it and callsgh 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".