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.
The same job also installs the packages the way an integrator would. If you
change anything in packages/*/package.json (exports, typings, type,
files), run this after building the packages:
./scripts/check-bindings-consumable.shIt packs all five packages, installs the tarballs into an empty project,
imports each one under Node ESM and typechecks a file that constructs every
Client with tsc --noEmit (module: nodenext). A broken exports map or a
missing type file fails it, even though npm pack --dry-run would not notice.
It needs network access to the npm registry. Build the packages first with
npm ci && npm run build in each packages/<name>, or by running
./scripts/frontend-with-local-bindings.sh, which builds them on the way.
The same job fails if a component shows a FIXTURE_* stand-in without its
"Sample data" tag. See Sample data in
the frontend guide for the rule, the allowlist and how to run the check
locally (npm run check:sample-tags --prefix frontend).
CI also checks that every page and component CSS file uses design tokens
(frontend/scripts/check-design-system.mjs), failing on hard-coded colours or
spacing off the 4px scale. See Design-system guard
for details and how to run it locally (npm run check:design-system --prefix frontend
or with --diff-base <commit> for diff mode).
Every #[contracterror] variant in contracts/ must have a plain-language
explanation in frontend/src/lib/errors.ts, so no failure reaches a user as
"unknown error". The bindings job checks both directions on every pull
request: a code the app does not explain, and an explanation for a code the
contract no longer has. Run it locally from the repository root:
python3 scripts/check-error-parity.pyIt lists each mismatch by contract, code and variant name. When you add an error variant, add its entry to the matching table in the same pull request.
Every route in frontend/src/routes.ts is rendered in jsdom, in both the light
and dark themes, and checked with axe-core.
A serious or critical violation fails the build. The contracts, the wallet and
the index are mocked, so the check never touches the network.
Run it locally from frontend/:
cd frontend
npm ci # first time, or after a dependency change
npm run test:a11y # every route, both themesThe accessibility job in .github/workflows/ci.yml runs the same command on
every pull request to dev and on pushes to dev and main. To silence a
rule, add an entry to ACCESSIBILITY_EXCEPTIONS in
frontend/src/test/accessibility.test.tsx with a reason; the suite fails on
an entry with no reason. Fixing the page is always preferred.
- Fixture Mode: Run
VITE_FIXTURES=1 npm --prefix frontend run devto run and inspect UI screens locally without requiring an active wallet connection or testnet funds. - Component Gallery: In dev mode, open
/dev/uiin the browser to inspect all components fromsrc/components/ui/andsrc/components/state/. It is left out of production builds, and CI fails if it ever reaches one (frontend/scripts/check-no-dev-pages.sh).
The frontend ships screenshot baselines for five key screens (landing page, programme directory, programme detail, funder dashboard, recipient dashboard), captured in light and dark themes at 360 px (mobile) and 1280 px (desktop) — 20 images total. CI compares every PR against those baselines and fails when a screenshot changes.
- The app is built in fixture mode (
VITE_FIXTURES=1) with a local indexer stub, so screenshots are fully deterministic: no testnet reads, no stale-index warnings, fixed wallet address, fixed programme list. - Playwright drives a headless Chromium and takes full-page screenshots with animations disabled.
- Baselines are committed to
frontend/tests/screenshots/and versioned with the code, so the diff is visible in review exactly like any other file change.
cd frontend
# First time only — downloads Chromium (~150 MB):
npx playwright install chromium --with-deps
# Run the screenshot suite (builds the app, starts the fixture server,
# compares against committed baselines):
npm run screenshotsA mismatch prints the diff path and exits non-zero. Open
frontend/playwright-report/index.html to see the pixel diff.
cd frontend
./scripts/update-snapshots.shThis rebuilds and re-runs Playwright with --update-snapshots, overwrites
the committed PNGs, and prints a git diff --stat reminder. Review the diff,
confirm only the intended screens changed, then commit the new baselines in the
same PR as the styling change.
The screenshots job in frontend/.github/workflows/ci.yml runs on every
pull request. On a mismatch it:
- Fails the build.
- Uploads
playwright-report/as a CI artefact (kept 14 days) so reviewers can open the HTML report without re-running the job. - Uploads
*-diff.pngfiles as a separate artefact for quick inline inspection.
To fix a failing screenshot job: run ./scripts/update-snapshots.sh locally,
verify the diff is intentional, and push the updated PNGs.
The scripts in examples/ show integrators how to use the published @milepost/* packages. CI typechecks them against the locally built bindings in the bindings job on every pull request, so a binding change that would break them fails the build rather than surprising an integrator.
To run the same check locally after changing a contract or regenerating bindings:
./scripts/typecheck-examples.shIt builds any binding package that has no dist/ yet, installs the TypeScript version pinned in examples/package-lock.json, overlays the local bindings and typechecks every script.
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.
The programme contract is mutation-tested with
cargo-mutants: each run makes small deliberate changes to
the contract and checks that a test fails on every one. On every pull request CI
mutates the programme lines you changed, and the mutants check fails if any
of those changes goes unnoticed by the tests. A full run over the whole
programme runs weekly and on demand.
If you change contracts/program/src/lib.rs, run the same check locally before
pushing:
cargo install --locked cargo-mutants # once
git diff dev... > /tmp/pr.diff
./scripts/mutants.sh --in-diff /tmp/pr.diff # what CI runs on your PR
./scripts/mutants.sh -F 'Programme::refund' # or just the functions you touchedThe script prints any surviving mutants grouped by function. Each one needs a
test that fails on it, or, if it genuinely cannot change behaviour, an entry in
.cargo/mutants.toml saying why. The testing guide
covers reading the report, the full run, and how to tell the two apart.
- 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
devusing the convention below, and open your pull request againstdev.mainonly moves whendevis released to it.
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>
Malware check
Every PR runs node scripts/check-malware.mjs. It fails if a lockfile pins a
package version known to be malicious, if a new dependency runs an install
script, if a package comes from outside the npm or crates.io registries, if a
workflow uses pull_request_target, or if a file contains hidden Unicode. If
your dependency is legitimately blocked, say why in the PR. A maintainer
records the exception, with its reason, in .github/malware-check-allowlist.json.
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.
CI fails if a #[contracterror] variant is never asserted by its own contract's tests (contracts/<name>/src/*test*.rs). A test counts if it names the variant as Enum::Variant (e.g. Error::FeeTooHigh) or matches its code as Error(Contract, #N). Mentions in comments do not count, and when two enums in one contract share a code, only the name does. Placeholders for removed variants (underscored names, rule 2 above) are skipped.
Run it locally from the repository root (no build needed):
./scripts/check-untested-errors.py # the check CI runs
python3 scripts/check-untested-errors.test.py # its own testsIf you add a variant, add a test that returns it. If a variant genuinely cannot be reached, list it in scripts/untested-errors-allowlist.txt as <contract> <Enum>::<Variant> <reason>. An entry without a reason fails, and so does an entry for a variant that is now tested or no longer exists, so when you write the missing test, delete its line in the same PR.
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.