Thank you for contributing. Each repository is an independent project and may
provide more specific instructions in its README, AGENTS.md, or
CONTRIBUTING.md. Repository-specific instructions take precedence over this
organization default.
- Create a focused branch from the repository's default branch.
- Make the smallest coherent change that solves the problem.
- Add or update tests and documentation where behavior changes.
- Run the repository's documented formatting, linting, and test commands.
- Open a pull request and complete the pull-request checklist.
- Merge only after required CI checks pass and review conversations are resolved.
The pull request title must be a conventional commit — feat:, fix:,
docs:, test:, chore:, and the rest, with ! for a breaking change. CI
enforces this and fails in seconds if it does not parse.
The title is not cosmetic. main takes squash merges, so the title becomes the
commit subject, and release tooling reads those subjects to choose the next
version and write the changelog. A title it cannot parse contributes nothing to
either.
A few repositories keep a long-lived develop branch that integrates work
before it reaches main — currently spread-foundry, ferro-wave,
vector-wave, and kalshi-ferro-demo. Feature branches there are still
squashed into develop, but the develop -> main pull request is merged with
a merge commit, not squashed. Squashing it would put a commit on main that
is not in develop's history, so develop would never become an ancestor of
main and every later release pull request would replay the whole backlog
against changes already applied.
Keep generated build outputs, dependencies, credentials, and local configuration out of commits.
Releases are automated and are not cut by hand. There are two tracks, and which one a repository is on is not a preference — it follows from whether it has private git dependencies.
Producers — repositories with no git = "…MorphIQ-Labs/…" dependencies.
These are the crates other repositories consume: ferro-risk, ferro-wave,
ferro-feed, ferro-match, ferro-replay, anvil, morphiq-platform. They use
release-plz:
- A
chore: releasepull request stays open, holding the version bump and the changelog entries derived from the commits since the last tag. - Reviewing and merging that pull request is the release.
- The tag follows automatically.
Consumers — repositories that pin a ferro-* crate by git tag, currently
gamma-foundry and spread-foundry. They use rust-tag-release.yml:
- Bump the version in
Cargo.tomlin an ordinary pull request. - Merging it to the default branch cuts the tag and the GitHub release, with a changelog grouped from conventional commit subjects.
- Repositories that build images pick the tag up through their own
on: push: tags: ['v*']trigger and stamp it onto every image.
Why the split is forced. release-plz's git_only mode reads the previous
release by running cargo package over the workspace, and cargo rewrites a
packaged manifest for a registry consumer: it strips the git spec and
replaces it with a crates.io lookup. The ferro-* crates are not published
there and will not be — distribution is git tags — so packaging fails with
no matching package named ferro-risk found. There is no way around it:
--no-verify skips the build but not the manifest rewrite, and
publish = false does not exclude a crate from cargo package --workspace.
Any new repository that pins a ferro-* crate belongs on the consumer track.
The version comes from the commit subjects, which is the other reason the
convention is enforced: a breaking change needs ! (or a BREAKING CHANGE:
footer) or the release will be numbered as though it were compatible. Note that
Cargo treats the minor as the breaking position below 1.0, so a breaking change
to a 0.x crate is 0.16.0 -> 0.17.0, never 0.16.1.
Nothing is published to a package registry. GitHub Packages has no Cargo format and neither does Google Artifact Registry, so Rust crates are distributed as git tags, which is what consuming repositories already pin.
The crate that owns the tag must stay publishable. release-plz filters
manifest-unpublishable packages through publishable_packages() before it
reaches its tagging step, so a publish = false in that crate's Cargo.toml
does not merely refuse publication — it drops the package before there is
anything to tag, turning every release into a silent "nothing to release". No
error, no warning. ferro-risk hit this in #228 and had to lift the setting.
More precisely: a crate must be publishable if anything else in the workspace
depends on it. release-plz runs cargo package over the workspace, and for
a dependent crate cargo strips the path and looks the dependency up in the
registry — so an unpublishable crate that something depends on fails with
no matching package named <crate> found. A crate nothing depends on may keep
publish = false: ferro-risk's streaming-bench and every fuzz crate do,
and they are fine precisely because they are leaves. The workspace-wide guard
lives in release-plz.toml, so nothing reaches a registry either way.
Every internal path dependency needs an explicit version —
{ path = "../platform-core", version = "0.2.0" }. cargo package refuses a
path dependency without a version requirement, and since release-plz packages
the previous tag's tree as well as the current one, a tag cut before this was
true can never serve as a baseline. It also settles cargo-deny, whose
allow-wildcard-paths exemption applies to private crates only, so a
versionless path dependency on a publishable crate fails the bans gate.
The practical consequence: the tree at every tag must satisfy all of the
above. A repository whose newest tag predates a crate rename, a workspace
split, or this versioning rule cannot be released until a fresh baseline tag is
cut by hand — fixing main does not help, because the old tree is the one
being packaged. ferro-match (v0.2.0, still holding iron-match-* crates),
ferro-replay (v0.1.0, before the ferro-clock/ferro-journal split) and
anvil (v0.1.0, versionless path dependencies) and morphiq-platform (v0.1.0,
same) each needed one. Cut it by bumping the version so the release job tags a
tree that satisfies the rules; from there the tooling owns the versions again.
Everything from here to the end of this section is the producer track only.
Consumers need none of it — no release-plz.toml, no packageable workspace, no
baseline tag — because nothing on that track runs cargo package. They need a
release.yml calling rust-tag-release.yml@ci-v1 with contents: write at the
call site and release-token: ${{ secrets.RELEASE_PLZ_TOKEN }}, and that is
all. The token must not be GITHUB_TOKEN there either: a tag pushed with it
does not trigger on: push: tags workflows, so the image build would never fire
and the release would silently produce nothing.
release-plz.toml at the repository root:
publish = false— keepscargo publishout of the pipeline.git_only = true— resolves the previous release from thev{{ version }}tag rather than querying the cargo registry. Without it release-plz asks crates.io what version the package is at, which is meaningless for a name that is not there and actively wrong for one that is: anvil ships a crate calledsdk, and an unrelatedsdkis published on crates.io, sosemver_checkwould diff our API against a stranger's.semver_check = true.git_tag_nameandgit_release_nameset to"v{{ version }}".- One
[[package]]entry for the crate that owns the tag, plus an entry for every other crate carryinggit_tag_enable = falseandgit_release_enable = false, all sharing oneversion_group. With a single tag per workspace, every crate would otherwise race to create the same ref.
.github/workflows/release.yml, calling
MorphIQ-Labs/ci-workflows/.github/workflows/rust-release.yml@ci-v1. Grant
contents: write and pull-requests: write at the call site: a reusable
workflow's permissions may only be equal to or more restrictive than its
caller's, and the organization default is read-only, so omitting them fails the
run at startup with no jobs and no logs to explain why.
The workflow uses the RELEASE_PLZ_TOKEN organization secret rather than
GITHUB_TOKEN. A pull request opened by GITHUB_TOKEN does not trigger
pull_request workflows, so the release pull request would never receive the
checks that the CI must pass ruleset requires, and could never be merged.
GitHub Actions, through the shared reusable workflows in ci-workflows. Rust
repositories call rust-ci.yml rather than defining their own gates, so fmt,
clippy, tests and the opt-in gates are described in one place and fixed in one
place.
Pin the shared workflow at @ci-v1:
jobs:
ci:
uses: MorphIQ-Labs/ci-workflows/.github/workflows/rust-ci.yml@ci-v1ci-v1 is a moving channel tag, advanced deliberately when the shared workflow
changes — the actions/checkout@v7 pattern. It sits outside the v* namespace
on purpose: v* is reserved for immutable release tags, because consumers pin
those (ferro-risk at tag = "v0.26.0") and a release tag that moved would
change a build silently.
Do not track @main. A repository that does takes every change the instant it
merges, and a fleet split between pinned and unpinned is how a broken shared
step can live in several repositories while being fixed in another.
The ferro-* family is authoritative. Retired iron-*, ferrum,
ferromatch, and related names must not be introduced into new code, copy, or
repository references.
Check a new crate's name against crates.io before using it, even though we
never publish there. When cargo packages an internal dependency it strips the
path and resolves the version requirement from the registry. If a public
crate happens to share the name and satisfies the requirement, cargo takes it —
silently, and builds against a stranger's API. morphiq-platform had a crate
called trading-calendar; an unrelated trading-calendar 0.2.3 exists on
crates.io, satisfied ^0.2.0, and the release build failed on
no associated function named nyse found for struct TradingCalendar, pointing
into index.crates.io-.../trading-calendar-0.2.3/. It is now
morphiq-trading-calendar.
This bites outside CI too: anyone running cargo package or cargo publish
locally gets the same substitution, and a public crate can publish a matching
version at any time. Generic names are the risk — sdk, common,
trading-calendar. Prefer a prefix that no one else would take.