Supply chain
What this project depends on, what it promises about those dependencies, and what stops a compromised or broken release from reaching a build. If you are reporting a vulnerability rather than reading about dependencies, start at SECURITY.md.
The two kinds of version number
Rust projects carry two, and confusing them is the usual source of bad advice.
| Where | What it is | Who it binds | |
|---|---|---|---|
| Requirement | Cargo.toml | tokio = "1.52" — a range | Everyone who depends on us |
| Locked version | Cargo.lock | tokio 1.52.4 — one exact build | This repository only |
Cargo.lock is committed and every CI build passes --locked, so a build here
cannot silently resolve a different graph than the one that was reviewed. But
the lockfile is not published and plays no part in a consumer's resolution.
When you add etl to your project, your resolver reads our requirements and
picks versions itself.
That is why nothing in [workspace.dependencies] is exact-pinned. An =1.2.3
requirement in a published library is imposed on every consumer, cannot be
overridden by them, and makes the crate unresolvable alongside anything else
wanting a different patch. The only exact pins are on this repository's own
crates, which move in lockstep.
Floors are a promise, and they are tested
A requirement of tokio = "1.52" claims the framework works with 1.52.0 and
everything compatible above it. That claim is checked: a nightly job resolves
every direct dependency to the lowest version its requirement admits and builds
the workspace. If a floor names a version that cannot work, the job fails.
The check is one-sided, and worth knowing. It catches a floor that is too low. A floor that is needlessly high still builds — it just narrows what consumers can resolve against — and no automated gate can detect that.
MSRV is Rust 1.94, on a rolling N-2 policy, checked in CI against the committed lockfile.
What must not leak into the public API
The full rule and its rationale are in
docs/DESIGN.md under Dependency policy. In short: no
rdkafka, clickhouse, or apache-avro type appears in a public trait bound or
public struct of etl-core, because those are 0.x crates and their breaking
releases must not become ours. The single exception inside etl-core is the
metrics facade, whose handle types are the instrumentation API, re-exported
so exactly one version of it lives in the tree.
The scope matters. Connector crates may deliberately re-export their
underlying crate, and some do — etl-avro exposes
pub type AvroValue = apache_avro::types::Value;
reachable as etl::avro::AvroValue, so an apache-avro 0.x release can break
code written against it. Those re-exports are marked in the rustdoc as exempt
from our stability promises, and they are the one place where a 0.x dependency's
breaking change becomes yours. If you are doing semver due diligence, that is the
surface to look at — not etl-core.
The controls
Each of these exists because of a specific failure, not as a checklist item.
Committed lockfiles, enforced
Both Cargo.lock and website/package-lock.json are committed. Every
dependency-resolving cargo invocation in CI passes --locked, and the docs site
installs with npm ci. Without this the lockfile is documentation: a job can
re-resolve and rewrite it in the runner, and nobody finds out.
Two invocations are deliberately outside that, each recorded inline where it
appears. cargo hack --no-dev-deps rewrites each Cargo.toml while it runs,
which makes the lockfile need an update and fails --locked outright. And the
minimal-version job runs cargo generate-lockfile -Z direct-minimal-versions,
whose whole purpose is to produce a different lockfile — it then builds that one
with --locked. In both cases the lint, test, deny and MSRV jobs run --locked
against the same commit, so lockfile drift is still caught.
Actions pinned to commit SHAs
Every uses: in every workflow is a full 40-character commit SHA with a version
comment, never a tag or branch. A mutable ref executes whatever the upstream
repository points at right now — there is no release event, so no cooldown or
review policy can gate it.
Pinning has a cost: you stop receiving upstream fixes automatically. The
compensating control is a zizmor job that fails CI on a known-vulnerable
action, an unpinned ref, a missing permissions: block, or a checkout that
persists credentials. It runs with a token, because without one it silently
skips the audits that need the API and reports success.
Pinning the action is only half of it, because an action that installs a tool
can still move which tool you get. The ones whose behaviour a gate rests on
are therefore pinned by tool version as well: cargo-deny (which reports the
security verdict), zizmor (whose default audit policy is the guarantee), and
cargo-about (which feeds a byte-exact diff).
Scoping never applies to the security gates
CI runs only the jobs a change can affect — a documentation edit does not build
the workspace, and a change confined to one connector does not boot every
container. That scoping is deliberately not extended to cargo deny, zizmor,
or the THIRD-PARTY.md diff gate, and the reason is worth stating because the
alternative looks reasonable.
A filter keyed on Cargo.lock and deny.toml would seem to cover
cargo deny, and does not: enabling a feature can pull a new crate into the
--all-features graph through a path no glob sees, and the advisory database
changes with no file in this repository changing at all. Filtering zizmor is
worse than incomplete — what it guards against is a pull request that edits
.github/, so gating it on .github/ having changed is circular. Those three
run on every pull request, unconditionally.
The same rule decides what may move to the scheduled tier: a check whose job is
to stop something entering main has to run before it enters main, because a
scheduled failure arrives after the merge that caused it. The scheduled tier is
for the opposite case — the world changing underneath a tree that has not.
Advisories, on a schedule as well as on pull requests
cargo deny check advisories runs on every pull request and nightly. The
nightly run is the one that matters: an advisory published against a crate
already in Cargo.lock is invisible to a pull-request-triggered gate until
somebody happens to open one. A failing nightly opens a tracking issue rather
than going unnoticed at 03:17.
Updates arrive after a cooldown
Dependabot proposes updates weekly, per ecosystem. A release must have sat on the registry for a soak period before it is proposed — the window in which a compromised or simply broken publish is normally caught and yanked.
| Ecosystem | Patch | Minor | Major |
|---|---|---|---|
| cargo, npm | 7 days | 14 days | 14 days |
| github-actions, docker | 7 days | 7 days | 7 days |
The minor column is 14 rather than 7 deliberately. Cooldown classifies a bump
positionally — by which number moved — while Cargo's caret rules make 0.39 → 0.40 a breaking change. Most of this dependency surface is 0.x, so without the
longer minor soak the breaking releases would get the shortest grace period.
The cost is that genuinely compatible 1.x minors wait 14 days too.
Actions and Docker get a flat 7 days because GitHub does not support the per-position keys for those ecosystems.
Security updates bypass the cooldown entirely, so a fix for a known advisory is never delayed by it. Nothing auto-merges.
npm lifecycle scripts are off
website/.npmrc sets ignore-scripts=true. Install hooks are how npm
supply-chain worms propagate: they run on npm ci, before any of the project's
own code, in a job with a checkout and a network. The docs site's ~1400 packages
are the largest dependency graph in the repository.
An .npmrc only covers installs that can see it, which is not all of them. The
docs deploy step runs a second npm install — cloudflare/wrangler-action
shells out to npm i wrangler@4 in a job with no checkout, so no .npmrc is
on disk and the production deploy token is already in the environment. That one
is covered separately, by setting NPM_CONFIG_IGNORE_SCRIPTS on the step, since
npm reads its configuration from the environment as well as from a file.
A new dependency cannot arrive unnoticed
THIRD-PARTY.md lists every crate in the shipped tree, is regenerated by CI, and
is byte-exact diff-gated. It is usually described as a licence artifact, but
it is also the control that makes the dependency graph visible: no crate can
enter the tree, from any source and at any depth, without producing a diff that a
human has to look at and accept. Combined with deny.toml's
unknown-registry = "deny" and unknown-git = "deny", a dependency cannot come
from anywhere but crates.io.
Accepting an advisory
deny.toml may carry an ignore entry for an advisory, and doing so is a
deliberate act with a written argument attached. Each entry needs:
- Why the vulnerable path is not reachable, or why the exposure is bounded. Not "it looks minor" — what the code actually does in this tree.
- Why there is no upgrade. Usually an upstream release that has not landed yet, named explicitly.
- The condition for removing it. An ignore without an exit is a permanent silent acceptance.
The reason field is the machine-readable half and shows up in tooling output,
so it has to stand alone rather than depend on the comment above it.
Current entries: one unmaintained proc-macro reached through clickhouse
(compile-time only, so it contributes no runtime or network surface), and two
denial-of-service advisories against quick-xml under object_store, where the
XML being parsed is the operator's own object store's list responses. All three
are conditional on an upstream release.
What is not covered
Stated plainly, so it is not mistaken for coverage:
-
npm dependencies have no advisory gate in CI.
cargo denycovers the Rust tree only. Alerts for the docs site's npm packages come from GitHub's dependency graph instead, which reads the committed lockfile — detection is covered, enforcement is not, so an advisory there cannot fail a build.Automated fixes are enabled but do not close every alert: when the vulnerable package is reached only transitively, Dependabot cannot find it in a manifest and will not open a security update for it. Those need the intermediate dependency to release, or an explicit
overridesentry. This affects the documentation site only — it is not part of any published crate. -
The minimal-version job does not run doctests, and never builds the minimal graph on the MSRV toolchain.
-
Nothing is published to crates.io yet, so there is no release provenance, no signature, and no attestation to verify. That arrives with the first release.
Related
- Licensing — the licence allow list, and the attribution
artifacts
THIRD-PARTY.mdand the full licence texts. - Installation — the feature flags that decide which dependencies your build actually links.
- SECURITY.md — how to report a vulnerability privately.