Skip to content

Internals Build CI Release

github-actions[bot] edited this page Jul 29, 2026 · 1 revision

What compiles, what gates a merge, what runs on a tag, and which of those you can safely ignore.

Features

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.

The ten workflows

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.

ThreadSanitizer

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.

What actually gates a merge

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 --ignored PTY TUI end-to-end tests.
  • featurescargo check --no-default-features --features X --tests across eleven feature sets: each of native, tls, api, mcp, hep, metrics, then tls,api, native,tui,audio, native,tui,tls,hep,api, native,hep,api,mcp,mcp-http and wasm (lib-only). The documented headless server recipe (native,hep,api,mcp,mcp-http) gets a full cargo test, not just a compile check.
  • auditcargo-audit and cargo-deny.
  • fuzz-check — the fuzz/ 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.

Hooks

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.

The toolchain

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.

Releases

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
Loading

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.

The changelog

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.

Re-measuring the benchmarks

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 5

Run 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
Loading

Clone this wiki locally