-
Notifications
You must be signed in to change notification settings - Fork 0
Internals Build CI Release
What compiles, what gates a merge, what runs on a tag, and which of those you can safely ignore.
Cargo.toml's [features] table has twelve entries: the eleven named
features below, plus default — which is native, tui, audio, metrics.
full is everything except wasm.
| Feature | Implies | Gates |
|---|---|---|
native |
— | Everything that cannot compile to WASM: libpcap capture, clap, the pcap file writer, the tracing subscriber. Most other features imply it. |
tui |
native |
The ratatui/crossterm terminal UI. |
tls |
— | TLS/SRTP decryption: ring, rustls, keylog parsing, and zeroize for key material. Does not imply native — decryption is useful in the WASM analyzer too. |
hep |
native |
HEP/EEP capture source, with HMAC auth. |
metrics |
native |
The standalone Prometheus /metrics listener. Raw TCP and threads, deliberately independent of api — you can ship metrics without the axum stack. |
audio |
— | The sipnab-audio plugin loader for TUI playback. Does not imply native; it is a dlopen of a separate cdylib. |
api |
native |
The axum REST API. |
mcp |
native |
The MCP server over stdio. |
mcp-http |
mcp + api
|
Streamable-HTTP MCP transport; depends on api so the axum stack is shared rather than duplicated. |
wasm |
— | The browser analyzer bindings. Lib-only: its test and bin targets are meaningless on the host. |
full |
everything but wasm
|
What the pre-commit hook and most local testing use. |
The implications that surprise people: tls and audio do not pull in
native, and mcp-http pulls in both mcp and api. A green
--features full therefore says nothing about whether --features tls alone
compiles, which is exactly why CI has a feature matrix.
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
push, PR | The merge gate. See below. |
quality.yml |
push to main, PR | Coverage (cargo-llvm-cov) and clippy SARIF upload. Not required by ci-success. |
codeql.yml |
push to main, PR | GitHub's static analysis. |
fuzz.yml |
weekly cron (Mondays 05:17 UTC) + manual | Coverage-guided cargo-fuzz runs; crash reproducers upload as artifacts. |
docker.yml |
push to main, v* tags |
Builds and pushes the image to GHCR with sigstore provenance. |
pages.yml |
push to main (path-filtered) | Builds and deploys the Zola website. |
scorecard.yml |
push to main, weekly cron (Mondays 07:20 UTC), branch-protection change | OpenSSF Scorecard posture analysis → Security tab. Report-only. |
wiki-sync.yml |
push to main (path-filtered) | Regenerates the wiki from docs/ via scripts/build-wiki.py. |
release.yml |
v* tags |
The release. See below. |
sanitizers.yml |
weekly cron (Tuesdays 06:11 UTC) + manual + on its own path | ThreadSanitizer over the threaded integration suites. Nightly as a tool, not as the toolchain. |
sanitizers.yml runs the threaded integration suites under
-Zsanitizer=thread, weekly. It exists because nothing else in the suite can
see a data race: the capture thread, the channel and the processing thread are
exercised by the tests, but a test that passes and a test that raced are
indistinguishable to cargo test. The borrow checker does not help here either
— it stops at unsafe, and 41 of this crate's 49 unsafe blocks are libc FFI
for privilege dropping and capture setup.
Three things about it are deliberate.
Nightly is a tool, not the toolchain. -Zsanitizer is nightly-only, and so
is cargo-fuzz; both run in their own workflow while the release build stays on
the pinned stable version. Moving the build to nightly would break the toolchain
pin, the MSRV promise, and the reproducibility of a released binary.
-Zbuild-std is required, not optional. Without rebuilding the standard
library with instrumentation, TSan sees std's synchronisation as opaque and
reports the channel itself as a race — the noise that gets a race detector
switched off.
The scope is a sample, and the workflow says so. Five suites run: the REST API and its token path, HEP, MCP stdio, and the CLI behaviour tests. A race in a path outside that list will not be found. Add the suite rather than assuming coverage.
ops/tsan/suppressions.txt silences libpcap, libasound and the dynamic loader,
which TSan cannot instrument. Each entry carries the reason it is not a real
race; an unexplained suppression turns a race detector into a race ignorer.
mimalloc is dropped under the sanitizer. RUSTFLAGS carries
--cfg sipnab_tsan, and src/main.rs skips its #[global_allocator] when that
cfg is set. mimalloc is C compiled by the cc crate, so -Zsanitizer=thread
does not instrument it, and TSan sees neither its alloc/free (no shadow reset on
a recycled block) nor its internal cross-thread synchronisation: every block
handed from one thread to another reads as a data race. The 2026-07-29 run
reported exactly that, and its stacks named read and
Vec::append_elements_unreserved with no allocator frame anywhere — so a
suppression could not have matched it. docs/design/backlog.md records the
bisect. The shipped binary keeps mimalloc; only the sanitizer build differs.
The verdict reads every process, and classifies. The step greps rather than
trusting the exit code (TSan can report a finding and still exit 0 when the test
passes), but two things make that grep mean something. log_path gives each
process its own tsan.<pid>: the suites spawn the sipnab binary and consume
its stderr, so before that, a report from a child reached the log only if some
test happened to print the child's stderr into an assertion message — the first
run's "0 races" meant "nothing was printed". And the verdict fails only on
race-class findings, listing anything else as a warning, because sipnab exits
several fail-fast paths through std::process::exit, which by design skips the
join in batch::run_loop; an unjoined capture thread at exit is expected and is
not a race. A missing __tsan_init in the built binary fails the job outright,
since a build that quietly lost its instrumentation would otherwise report a
clean run forever.
ci-success requires exactly four jobs: check, features, audit,
fuzz-check.
-
check(per-OS matrix) —cargo build --all-features,cargo test --all-features,cargo clippy --all-features --all-targets -D warnings,cargo fmt --check,cargo doc --no-deps --all-features --workspace, plus the--ignoredPTY TUI end-to-end tests. -
features—cargo check --no-default-features --features X --testsacross eleven feature sets: each ofnative,tls,api,mcp,hep,metrics, thentls,api,native,tui,audio,native,tui,tls,hep,api,native,hep,api,mcp,mcp-httpandwasm(lib-only). The documented headless server recipe (native,hep,api,mcp,mcp-http) gets a fullcargo test, not just a compile check. -
audit—cargo-auditandcargo-deny. -
fuzz-check— thefuzz/workspace compiles.
install-sh and deb-package are not in that list. They run on every push
— the installer test suite plus shellcheck, and the .deb build for both the
full and noaudio variants — but a failure in either does not block a
merge. If you touch website/static/install.sh or packaging/deb/, read their
logs yourself; nothing else will make you.
Activate once per clone: git config core.hooksPath .githooks.
pre-commit runs eight numbered gates, in order:
clippy (--features full, -D warnings); the full test suite; no
unwrap()/expect() in production code; WASM exports in sync with the site's
JS; the homepage test count matching the run it just did — plus the site and
man-page version strings matching Cargo.toml; no TODO stubs; a refusal to
commit a staged src/wasm.rs without a rebuilt bundle beside it; and an
advisory notice when a commit touches a file docs/internals/ cites without
touching docs/internals/ itself.
Two of the eight cannot fail the commit. Gate 6 prints
WARN: N TODO/FIXME comments and falls through — a count, not a veto. Gate 8
prints REVIEW and a list and returns zero, a reminder to check the developer
pages still read true, not a claim that they don't. The gate that does fail
is dev_docs_drift_test in gate 2's test
run, and it is broader than dead links: sixteen tests covering cited paths that
no longer exist, a ()-suffixed symbol in link text with no matching fn left
in the workspace, an absolute GitHub URL where a relative path belongs, a page
missing from build-wiki.py (which would silently never publish), and three
mermaid conventions — sequenceDiagram only, no markdown links inside a fence,
and a prose line above every one. What it cannot catch is prose that has
quietly become false; that is still a human's job.
That means every commit runs clippy and the whole test suite and takes
minutes. It is not optional theatre: the homepage-count gate alone means adding
a test obliges you to update website/templates/index.html in the same commit.
pre-push adds four hard gates that cargo test
does not cover: cargo fmt --check, cargo clippy --all-features --all-targets -D warnings, cargo doc with RUSTDOCFLAGS=-D warnings, and cd fuzz && cargo check. Rustdoc lints and the separate fuzz workspace compile
independently of the test build, so these are exactly the failures that
otherwise appear ten minutes later in CI. SKIP_FMT_HOOK=1 bypasses all of
them; if you use it, expect CI to notice.
Both hooks have their own test scripts —
test-pre-commit.sh and
test-pre-push.sh.
install-from-source.sh is unrelated to
the hooks: it is the developer-facing source install (cargo install --path . --bin sipnab, then --setup-caps on Linux). It passes --bin deliberately —
without it, gen_fixture also satisfies its required-features and lands in
the caller's ~/.cargo/bin.
Rust 1.97.1, pinned across seven files and enforced in none of them locally:
| Location | Form |
|---|---|
ci.yml (3 jobs), quality.yml (3 jobs), release.yml
|
dtolnay/rust-toolchain@<sha> # 1.97.1 |
Cargo.toml, crates/sipnab-audio/Cargo.toml
|
rust-version = "1.97" (MSRV) |
Dockerfile, harness/sipnab/Dockerfile
|
FROM rust:1.97-slim-trixie@sha256:<digest> |
The action is pinned by commit SHA, so the version lives in the trailing
comment — which makes that comment load-bearing rather than decorative.
rust_toolchain_pins_agree reads it, and also asserts the ref really is a
40-hex SHA: an edit dropping back to @1.98.0 would otherwise contribute
nothing to the comparison instead of failing it. The Dockerfiles keep the tag
beside the digest for the same reason — that gate parses FROM rust:X.Y.
There is no rust-toolchain.toml, so your local rustup default is
whatever you last set — nothing in the repo corrects it. This is not
hypothetical: the changelog records CI pinned at 1.94.1 while local development
ran 1.97.1, and clippy consequently validated against a different compiler than
the one gating merges. If you add a rust-toolchain.toml, update every row
above in the same change.
A release is a pushed v* tag. release.yml then runs a matrix of eight
builds: x86_64 and aarch64 for linux-gnu (each also in a noaudio
.deb-only variant), x86_64 and aarch64 linux-musl, and both macOS
architectures. The gnu targets build inside a rust:1-bookworm container so
their glibc floor is 2.36; aarch64 gnu cross-compiles inside that same
container via Debian multiarch rather than cross-rs, for the same reason.
Every gnu binary then passes a readelf -V gate that fails the build if it
links a GLIBC_ symbol newer than 2.36 — a regression guard on the build
environment, not on the code. Artifacts are checksummed into SHA256SUMS.txt
and attested with actions/attest-build-provenance, so a downloader can run
gh attestation verify <file> --repo NormB/sipnab.
Two CycloneDX SBOMs ship with each release and are covered by both the
checksum file and the attestation: sipnab-<version>.cdx.json for the binary
and sipnab-audio-<version>.cdx.json for the playback plugin. Two, not one,
because the plugin is a separate workspace crate loaded with dlopen and it
pulls in seven dependencies the main crate's graph does not contain at all —
alsa, alsa-sys, cpal, dasp_sample, num-bigint, num-rational,
rodio. A single main-crate SBOM would omit precisely the C-library-adjacent
dependencies a vulnerability scan is looking for, while looking complete.
The binary SBOM is generated with --features full on purpose. The noaudio
artifacts resolve a strict subset of that graph — measured on 0.5.54 the two
differ by exactly one component, libloading — so one full-feature document
over-covers rather than under-covers every binary published, which is the safe
direction. cargo-cyclonedx has no --package flag: one workspace-level
invocation emits a document per member into that member's own directory, and
the release step renames them apart on the way into artifacts/.
Finally the tap job
renders the Homebrew formula and pushes it to NormB/homebrew-tap, skipping
with a warning if the token is absent.
The tag is what starts it, and everything after is automatic.
sequenceDiagram
autonumber
participant Dev as you
participant Rel as release.yml
participant Gate as glibc floor gate
participant GH as GitHub release
participant Tap as homebrew-tap
participant GHCR
Dev->>Rel: git push origin v0.5.x
Rel->>Rel: build matrix (8 targets, bookworm for gnu)
Rel->>Gate: readelf -V each gnu binary
Gate-->>Rel: max GLIBC symbol <= 2.36 or fail
Rel->>GH: SHA256SUMS.txt + attest-build-provenance
Rel->>Tap: render formula, push if token set
Dev->>GHCR: same tag triggers docker.yml in parallel
Note over GHCR: image + sigstore provenance
Version strings live in Cargo.toml, website/config.toml, man/sipnab.1,
fuzz/Cargo.lock and several docs, and committing with any of them out of step
fails — so bumping a version is one edit plus the ones you will be reminded
about.
That enforcement lives in one place: docs_current_version_markers_match_cargo
and man_page_version_and_license_match_cargo in
tests/docs_drift_test.rs, which the hook
runs via cargo test and CI runs again. The hook used to carry its own shell
re-implementation with a separate file list; the two diverged and it rejected a
correct release commit over a deliberately historical version reference. If you
need to change which docs carry a marker, change the Rust list.
Note which files are deliberately excluded: pages that record the release something was measured on — the benchmarks pages — must not track the crate version. A marker forcing them to is what kept a stale benchmark claim looking freshly checked for twenty-nine releases.
The test count in website/templates/index.html is gated the same way, by
ci.yml against the real suite total. That check is Linux-only: platform-gated
tests mean the macOS leg runs a handful fewer, so one advertised number cannot
be true of both, and the figure describes the Linux run.
CHANGELOG.md is based on Keep a Changelog
and says so in its header. Entries are grouped under Added / Changed /
Fixed / Removed, and the project uses a few extra headings where those four
do not fit what a release actually did.
sipnab is pre-1.0, so the header states the versioning policy rather than claiming strict Semantic Versioning: the public API and CLI surface are not stable and a breaking change may land in any release. Say so in the entry that carries one.
Nothing gates the changelog's contents, which is deliberate — a gate on prose
would be satisfied by prose. What is gated is the release date: it must match
website/config.toml, asserted by site_release_date_matches_changelog.
The published throughput numbers are not gated by CI, deliberately. Shared
runners are too noisy for a throughput threshold: such a gate fails randomly,
gets muted, and a muted gate is worse than no gate — it reports safety it is
not providing. quality.yml therefore executes the criterion suites without
timing them, which is "the benchmarks still run", not "performance has not
regressed".
Detecting a real regression is a release-time step on the reference host, because that is the only place the numbers mean anything:
# Run all of these, in order.
gh release download vX.Y.Z -p 'sipnab-*-aarch64-unknown-linux-gnu.tar.gz*'
sha256sum -c sipnab-*-aarch64-unknown-linux-gnu.tar.gz.sha256 # never a dev build
tar xzf sipnab-*.tar.gz
python3 bench/carrier.py --calls 5000 --out corpus.pcap
bench/scaling.sh ./sipnab-*/sipnab corpus.pcap 535000 --cores 1,2,4,8 --runs 5Run it on an otherwise idle machine. If the numbers moved, A/B the previous release artifact against the same corpus in the same session before concluding anything — a corpus or session difference looks exactly like a regression, and the benchmarks page records one occasion where it was mistaken for one. Update both benchmark doc trees and the homepage tiles together; gates enforce that they agree.
Everything a change passes through, in the order you meet it:
sequenceDiagram
autonumber
participant Dev as you
participant PC as pre-commit
participant PP as pre-push
participant CI as ci.yml
participant Agg as ci-success
Dev->>PC: git commit
PC-->>Dev: clippy, tests, unwrap scan, wasm sync, versions, TODOs
Dev->>PP: git push
PP-->>Dev: fmt, clippy --all-features, cargo doc, fuzz check
Dev->>CI: push lands
CI->>Agg: check, features, audit, fuzz-check
CI->>CI: install-sh, deb-package
Note over CI,Agg: install-sh and deb-package run but are NOT required
Agg-->>Dev: green or a named failed job
Website · Repository · Issues · Generated from docs/ — edit there, not here.
Getting started
Using the TUI
CLI & automation
Configuration
Integrations (API & MCP)
Development & internals