Welcome, and thank you for your interest in contributing to Milepost! This guide covers everything you need to go from a clean checkout to an open pull request: local setup, running checks, documentation standards, and the issue workflow.
- Prerequisites
- Local Setup
- Frontend
- Running Contract Checks
- Documentation Standards
- Issue and PR Workflow
- Error Code Stability Policy
- Release Process
| Tool | Minimum version | Notes |
|---|---|---|
| Node.js | 18+ | 22 is recommended for CI alignment |
| npm | 8+ | Do not use pnpm or yarn — it creates lockfile conflicts |
| Rust + Cargo | stable (1.74+) | Install via rustup.rs |
| wasm32v1-none | — | rustup target add wasm32v1-none |
| Stellar CLI | 21+ | Installation guide |
| Freighter wallet | latest | freighter.app — browser extension for UI testing |
Clone
git clone https://github.com/milepost-labs/milepost.git
cd milepostThe web app is in frontend/, with its own setup guide in
frontend/CONTRIBUTING.md.
Pre-commit hooks (opt-in)
To automatically check formatting (rustfmt) on changed files before each commit:
./scripts/install-hooks.shThe hook runs only on staged files for speed. If you need to bypass it in an emergency, use git commit --no-verify.
Development Container
If you use VS Code or GitHub Codespaces, you can open the repository in a pre-configured Dev Container that includes all pinned toolchains (Rust stable with wasm32v1-none, Stellar CLI 27.1.0, and Node.js 24):
- Open the project in VS Code.
- When prompted, select Reopen in Container (or run
Dev Containers: Reopen in Containerfrom the Command Palette). Tool versions in.devcontainer/Dockerfilematch.github/workflows/ci.yml. When updating toolchain versions, update both.
Task Runner (just or make)
Common commands and workflows are available via just (or standard make):
just build/make build— Build contract WASM artifactsjust test/make test— Run workspace tests (encodes WASM build order dependency)just lint/make lint— Runcargo fmtcheck andcargo clippy(encodes WASM build order dependency)just frontend-local-bindings/make frontend-local-bindings— Build and test the app infrontend/against this checkout's bindings after changing a contractjust deploy/make deploy— Deploy contracts using./scripts/deploy.shjust seed/make seed— Seed protocol scenario data using./scripts/seed.shjust/make help— List all available tasks
The web app lives in frontend/, and the indexer that builds the
lists it reads in milepost-indexer.
Both have their own contributing guides and checks, and both install published
@milepost/* versions, so neither sees a contract change until it is released.
If you change a contract's interface, or any event it emits, check them against your change before opening the PR, with milepost-indexer cloned next to this repository:
./scripts/frontend-with-local-bindings.sh frontend ../milepost-indexer
npm run build --prefix frontend && npm test --prefix frontend
npm run typecheck --prefix ../milepost-indexer && npm test --prefix ../milepost-indexerAn event rename is the case to watch: the app may not touch the event at all, while the indexer stops decoding it and quietly publishes shorter lists. Its tests decode events through the bindings' own specs, so they fail on it.
CI does the same on every PR, in the bindings job.
Milepost's Soroban contracts are structured as a workspace in the root directory (e.g., contracts/attest, contracts/program, contracts/registry, etc.).
Run these checks from the root of the repository:
# Format check
cargo fmt --all --check
# Lint
cargo clippy --workspace -- -D warnings
# Unit tests
cargo test --workspaceTo build the WASM artifacts for deployment:
stellar contract buildThe compiled outputs will land in target/wasm32-unknown-unknown/release/.
The wasm build must come first. registry's tests contractimport! the
built programme, so running clippy or tests before building produces failures
that look like unrelated logic errors — and a stale artifact silently tests the
wrong contract. See the testing guide for the ordering
rule, bindings regeneration, and the generated-file and fixture traps that have
cost review rounds.
- Code comments: Only add a comment when the why is non-obvious — a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not comment on what the code does; well-named identifiers already do that.
- Contract interface changes: Any change that touches the contract state or upgrade flow must be explicitly documented. Breaking changes require an explicit version bump and testnet verification before the PR can be merged.
Picking up an issue
- Comment on the issue to let others know you are working on it.
- Fork the repository and clone your fork.
- Create a branch from
mainusing the convention below.
Branch naming convention:
<type>/<short-description>
Examples:
feat/verifier-dashboardfix/tranche-unlock-logicdocs/contributing-guide
Commit messages Write imperative-mood subject lines under 72 characters. Put context in the body when needed.
feat: add full-screen layout to the landing page Replaces the centered hero section with a split-screen design.
Pull request checklist Before marking a PR ready for review:
- Contract checks pass if Rust files were touched (
cargo fmt,cargo clippy,cargo test) - If a contract interface changed, the app in
frontend/still builds and tests against it (see Frontend) - PR description references the issue number(s) with
Closes #<number>
If you are modifying the Soroban smart contracts, error codes are part of the contract's public API and are matched on by SDK consumers and off-chain monitoring tools.
Rules
- Never reassign a published discriminant. Once an error code has been included in a release, that numeric value is permanently reserved — even if the corresponding variant is removed.
- Mark removed variants as reserved. Replace a deleted variant with an underscored placeholder and annotate it with a
// (reserved — removed variant)comment. This makes the gap self-documenting and prevents accidental reuse. - Always assign new variants the next available integer. Do not insert variants mid-sequence; append them at the end of the enum.
Why this matters: Stellar contract error codes propagate as u32 values in the transaction result. If we reused a previously published discriminant, an indexer or bot that matches on that numeric value could silently misinterpret a new error as an old, unrelated one.
A release is a git tag. Pushing a tag matching vX.Y.Z (or a pre-release like
v0.1.0-rc.1) triggers .github/workflows/release.yml,
which:
- Builds every contract to
wasm32v1-nonerelease wasm — the same build CI already verified on the PR that merged. - Hashes each
.wasmwith SHA-256 (checksums.txt). A programme is instantiated from a wasm hash (see the root README), so the hash is what downstream teams pin to — not a crate version. - Generates a changelog from the commits since the previous tag
(git-cliff, configured in
cliff.toml) and publishes a GitHub Release with the wasm files,checksums.txt, and the changelog as the release body.v*-*tags (e.g.-rc.1,-beta.2) are published as pre-releases. - Builds the five TypeScript binding packages at the tag's version, checks
that each tarball actually contains
dist/, and publishes them to npm as@milepost/*through trusted publishing, which also attaches provenance. This runs before the GitHub Release is created, so a broken release — a package that would ship withoutdist/, or a trusted publisher that is missing or misconfigured — stops cleanly instead of leaving a GitHub Release with no packages. A pre-release tag publishes under thenextdist-tag, sonpm install @milepost/programnever resolves to one, and packages already published at that version are skipped, so a failed release can be re-run.
Nothing in the workflow edits Cargo.toml or commits back to the repo —
tagging is a manual, deliberate act, not an automated bump.
Publishing needs no npm token, and token publishing is disallowed on all five
packages. Each of them on npmjs.com has a trusted
publisher for GitHub Actions
— organisation milepost-labs, repository milepost, workflow release.yml,
no environment, direct publishing allowed — and the release job authenticates
with its own short-lived OIDC credential. Those values are case-sensitive and
npm does not check them when they are saved. After adding one, reopen the
package's settings and confirm the connection is listed: the form can be left
without saving, and nothing warns you.
A missing or mismatched trusted publisher does not fail as an authentication
error. npm falls back to an unauthenticated upload, and the publish step fails
with E404 Not Found on the PUT for that package ("could not be found or you
do not have permission to access it"). Packages later in the loop are never
attempted, nothing is published and the GitHub Release is not created, so fixing
the configuration and re-running the failed job is enough — no new tag.
A package has to exist on npm before a trusted publisher can be added to it, so a new binding package must be published once by hand before a release can take it over.
git tag v0.2.0
git push origin v0.2.0Pick the version by ordinary semver judgement (see below); nothing derives it for you from commit history.
Commit subjects already follow <type>(<scope>): <description>. To flag a
commit as breaking a contract's public interface — its client-facing
functions, its error codes (section 7), or its storage/upgrade layout — do
either of:
- Append
!after the type/scope:feat(program)!: change award() signature - Add a
BREAKING CHANGE: <explanation>footer to the commit body.
Either form makes git-cliff place that commit in its own "⚠ Breaking changes"
section at the top of the release notes, separate from ordinary Features/
Fixes entries, so a downstream integrator scanning a release can see at a
glance whether upgrading is safe. A deployed Soroban contract can't be
patched in place — a breaking release means existing callers need to
re-integrate against a new deployment, so treat this marker as required, not
decorative.
The workspace's crates all share version = "0.1.0" in the root
Cargo.toml and none are bumped automatically by this workflow — the git
tag, not the crate version field, is the source of truth for what a release
is. Bump the workspace version by hand when a tag represents a meaningful
jump (e.g. the first 1.0.0), but a mismatch between the tag and the crate
field is expected and not a bug: contracts here are consumed as compiled
wasm pinned by hash and as the generated TypeScript bindings in packages/
(see the README), not as a Rust library
dependency.
This release process produces GitHub Release artifacts only; nothing here publishes to crates.io. That's a deliberate choice, not a gap:
milepost-policy-spenddepends onsmart-wallet-interfacepinned to a git revision (see itsCargo.toml) because that crate isn't published either — crates.io rejects git dependencies, so this crate can't be published as-is.- The actual integration surface for another Stellar team is a deployed wasm
hash plus the generated TS bindings, not
cargo add milepost-attest. None of the five contracts expose a Rust API meant to be depended on externally. - All crates are pre-1.0 and workspace-versioned together; publishing now would mean maintaining crates.io semver guarantees before the interfaces (and this release process itself) have stabilized.
If a real Rust-dependency use case shows up later, revisit this — starting
with milepost-types, the one crate with no unpublished dependency in its
way — but raise it as its own issue rather than folding it into this
workflow.
Push a pre-release tag (vX.Y.Z-rc.N) on your own fork before trusting a
change to this workflow, and check the Actions run produces a pre-release
with wasm files, checksums.txt, and a non-empty changelog attached.