vike
Developer

Drift gates

The committed-generated-artifact family — why two build outputs are committed rather than produced, how each is re-derived and compared, and why the comparison styles differ.

A few build outputs in Vike Trader are committed rather than produced at build time, each to keep a whole toolchain — or a whole upstream — out of a clean checkout. That trade buys a fast, dependency-light build and costs one thing: a committed artifact can drift from the source it came from, silently, and no compiler will notice.

The family rule is three clauses, and every member obeys all three: re-derive from source in CI, compare exactly, and pin the generator beside the artifact — not in a central document, which is the copy that rots.

The two members

The cTrader protobuf bindings. crates/bridges/ctrader/src/generated/openapi.rs is pre-generated and committed, so a normal build never runs prost-build or protoc — they stay out of the build graph and the supply chain. A path-filtered workflow regenerates from the vendored schema with the pinned toolchain and fails unless the committed file is byte-identical, a plain cmp whose reason crates/bridges/ctrader/src/proto.rs's generated module states beside the include!: the committed file is prost-build's verbatim output for the vendored schema, renamed on commit.

The vendored IBKR wire-protocol client. crates/bridges/vike-ibkr/vendor/ibapi is a workspace-excluded copy of an upstream crate, re-derived by a path-filtered workflow that runs a standalone drift script. The check lives in a script rather than inline in the workflow because it is not one command: it reconstructs, in stages, a vendoring procedure a person performs by hand. crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md's Refresh procedure is that procedure — re-clone upstream at the desired tag, re-copy src/, Cargo.toml and LICENSE, re-apply the three manifest trims and the three test-module drops, update the record's own fields — and it ends by telling the refresher to "run the gate before pushing", naming the same script the workflow runs. A check the next refresher must run locally cannot live inside a workflow file.

Both are separate, path-filtered workflows rather than jobs in the main CI run, and both carry SHA-pinned actions, a read-only token and a job timeout — the runner also runs live trading.

Why the comparison styles differ

The looser-looking gate is the correct one

The bindings are compared byte-for-byte. The vendored client is compared three different ways in one run, and its src/ under rustfmt normalization rather than as raw bytes. That is not a weaker gate written by a tired author — it is the only comparison that could have been green the day it was written.

The drift script compares the three things that were copied — src/, Cargo.toml and LICENSE, the set crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md's "What was copied" names — and each on its own terms, argued beside the code that performs it. LICENSE gets a raw cmp: nothing processes it on either side. Cargo.toml gets a raw cmp after applying the record's documented trims and two declared normalizations — measured 2026-08-22 against the pinned upstream commit, the trims are byte-reproducible, "the whole difference being the vike provenance banner at the top of the committed file and one trailing blank line."

src/ is compared as rustfmt-normalized text, because the raw bytes are not reproducible and never were. Upstream ships a rustfmt.toml with max_width = 150; that file was not among the copied ones, and the copy was reformatted under this repository's own config — crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md records both, in its "What was copied" section. Measured 2026-08-22 at the pinned commit, 276 of the 280 committed .rs files differ from upstream, every difference line-wrapping and none of it code — and running this repository's rustfmt over upstream's src/ collapses that to zero. A byte gate there would have been red on the day it was written.

The normalization is honest about both sides of the trade. Both trees go through the same rustfmt, which re-prints parsed source while preserving comments verbatim — so a changed identifier, a changed literal, an added or removed statement, an edited comment, a reordered item and an added or removed file all survive it and redden the gate. What it cannot see is whitespace and wrapping inside the committed files: "rustfmt erases nothing but layout, so the residual is layout alone."

Where each pin lives

The third clause keeps the other two meaningful. The bindings' regen recipe is a committed, standalone project that =-pins its codegen crates and commits its own lockfile — freezing the pretty-printer whose output must match byte-for-byte — and CI regenerates with --locked. Those pins may be bumped only together with a re-commit of the generated file.

For a vendored copy the only build input is an upstream commit, so the pin lives in the vendored tree's own VENDORED.md and the gate parses it there rather than carrying a second copy a refresh would have to remember to update. It insists on a full 40-hex commit, because "a tag or a branch can be moved under us, and an abbreviated sha can collide", fetches that commit rather than a ref, and cross-checks the recorded version against the committed manifest's — "a half-finished refresh is exactly the state where they disagree."

The vendoring is not a pure copy. One set of local edits — three test-only modules dropped from src/ — was undocumented until the gate measured it, and they are reconstructed rather than ignored, because "a gate that merely IGNORED files missing from the copy would let the next refresh drop a load-bearing source file in silence."

The member that left, and the rule it left behind

The JForex sidecar jar was the third member: it was committed, and a dedicated workflow rebuilt it from the committed Java sources to prove the two still agreed. It is a runtime artifact rather than a compile input, so the release workflow now builds it from those sources on every tag and attaches it to the Release, checksummed in that release's SHA256SUMS, and a release-tools fetch script installs it into the runtime-tool directory crates/vike-model/src/state_path.rs's PROJECT_BIN_DIR names — "so there is no second copy to drift from and nothing a git diff could fail to notice."

Deleting the workflow with the question would have been wrong, because two properties it rested on are still true and now more load-bearing: "the release ships bytes nobody reviews, and a SHA256SUMS line is only worth anything if somebody can re-derive the bytes it describes." So it became a reproducibility gate — it builds and tests the jar, runs a protocol smoke, then builds it twice from a clean directory and compares the two byte for byte. The build script had claimed "same sources plus same JDK gives a bit-identical jar" all along, a claim nothing ever checked, because the old gate compared a build against a human-committed artifact and "could not tell reproducibility from luck."

The rule this leaves: an artifact a deployed binary must find is a release asset, never a committed one — commit the pin, ship the build.

The pin outlived the membership. crates/bridges/dukascopy/tests/jdk_pin_gate.rs holds the JDK version equal across four files — the gate, the release that builds the shipped jar, and both provisioning scripts — because javac output differs across JDK releases. Its header names the failure with no other detector: if the gate's pin and the release's pin drift, "the gate reproduces a jar nobody has, the release publishes a jar nothing checked, and both workflows stay green."

Related: Supply chain · Release gates · Docs gates

On this page