1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
name: CI
# Development lines are covered, and publication is not on this path (0.15.20,
# [D-262], review C-23).
#
# [D-234] found sixteen releases whose CI had never run, and set the policy
# "branches live under pull requests; push triggers stay main-only" — widening
# `push:` was rejected there because it double-runs a branch that has a pull
# request open. The policy is correct and it did not hold: it depends on a
# person remembering to open a draft pull request per line, and for `dev/0.16.0`
# nobody did, so nineteen more releases went unreplicated. That is D-234's own
# defect arriving through the remedy's one manual step. A trigger nobody has to
# remember is worth the duplicate runs, and the duplicate is bounded — it costs
# runner minutes while a pull request from a `dev/**` branch is open, and
# nothing else. No concurrency group cancels it, because cancelling a
# superseded run would trade a documented cost for an undocumented weakening of
# D-234's standing obligation: **a run must exist for the pushed SHA**, and a
# release commit pushed shortly before the next one would lose its evidence.
#
# **Nothing here can publish.** `release.yml` (crates.io) and `wheels.yml`
# (PyPI) trigger on `push: tags: ["v*"]` and `workflow_dispatch` only, and a
# branch push creates no tag. Their publish jobs are guarded a second time by
# `startsWith(github.ref, 'refs/tags/v') || inputs.publish == true`, which a
# `refs/heads/dev/**` ref fails. They reach this file through `workflow_call`,
# which is a separate entry point a push trigger cannot fire. Two layers, and
# widening the branch filter moves neither.
on:
push:
branches:
pull_request:
workflow_call: # so release.yml can gate on exactly this
env:
CARGO_TERM_COLOR: always
# Action versions, and why these particular majors (0.8.0, A4, D-112).
#
# `actions/checkout@v4`, `setup-python@v5`, `upload-artifact@v4` and
# `download-artifact@v4` all declare `runs.using: node20` — verified by reading
# each action's own `action.yml`, not inferred from the deprecation banner. Node
# 20 is deprecated and force-migrated to Node 24, which annotated every job of
# every run across all four workflow files.
#
# Each is bumped to the FIRST major that declares `node24`, which is a different
# number for each of them: checkout v5, setup-python v6, upload-artifact v6,
# download-artifact v7. Not to the newest (v7 / v7 / v7 / v8) — that would also
# take an ESM migration, `download-artifact`'s new hash-mismatch enforcement,
# `upload-artifact`'s direct-upload semantics and `checkout`'s fork-PR blocking,
# none of which this repository needs and none of which can be tested anywhere
# but on CI itself. The goal is to stop running a deprecated runtime, and that
# is exactly what these four buy.
#
# `download-artifact` v5's one real breaking change — the output path for
# single-artifact downloads **by ID** — does not apply here: `wheels.yml` names
# no artifact and uses `merge-multiple: true` across all of them.
#
# Not bumped, because they are already fine: `Swatinem/rust-cache@v2` and
# `PyO3/maturin-action@v1` both declare `node24` on their current major, and
# `pypa/gh-action-pypi-publish@release/v1` is a composite action with no Node
# runtime at all. `dtolnay/rust-toolchain` is a branch ref by design.
# Every job below carries a `timeout-minutes`, and so does one step (0.15.25,
# [D-267]).
#
# Not tuning. Without one, a job inherits GitHub's default of **360 minutes** --
# a number nobody in this repository chose, which arrived by omission, and which
# no job here has ever come within four times of. A cap that is never reached
# costs nothing. The run it exists for is the one that stops making progress,
# where the difference is a job that fails in an hour against a runner held for
# six and a queue behind it.
#
# Each number is roughly three to five times the maximum observed across the
# twelve runs before this commit, and the comment beside it records what that
# maximum was -- so a later reader can tell a bound that was measured from a
# bound that was guessed, and can see when a job has grown into its cap rather
# than discovering it the day the cap fires.
jobs:
lint:
name: clippy
runs-on: ubuntu-latest
# clippy, rustdoc and the fuzz-crate check; max observed 2.4 min.
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
# `--all-targets` covers benches and examples, which is where several of
# this project's diagnostics live and where clippy has caught real defects
# (D-075's oversized DbError came out of a clippy warning). Verified clean
# under `-D warnings` at 0.6.0, so this is a gate that currently passes.
#
# `-D warnings` is scoped to this step rather than set for the workflow:
# as a global RUSTFLAGS it also applies to the `cargo publish --dry-run`
# verification build, where a warning from a dependency's build would fail
# a packaging check that has nothing to do with warnings.
#
# **`property-tests` is in the feature list, and was not until 0.8.0.**
# `--all-targets` only reaches targets that the selected features compile,
# so the three quarantined generated-history binaries — which are gated
# behind `property-tests` — were never linted by this gate at all. That
# was found by accident in 0.8.0 (D-124), and what it was hiding was an
# unused import in `graph_property_tests` that had survived every green
# run. Linting a binary costs nothing here whether or not it is *run*
# under a feature gate, so the two lists have no reason to differ.
- name: clippy
run: cargo clippy --all-targets --features "metrics property-tests"
env:
RUSTFLAGS: -D warnings
# `metrics` is a default feature since 0.12.11 (D-154), and turning it off
# is a configuration this crate supports: `ActorMetrics` becomes an empty
# ZST and `record_hold` a no-op. Nothing here ever built that arrangement,
# so it was verified only by hand — and in 0.12.23 an example was added
# that calls `db.metrics()` with no `required-features`, which broke
# `cargo test --no-default-features` for two releases with every gate
# green (D-169).
#
# `--all-targets` is the point rather than a flourish: the break was in an
# example, and a plain `cargo check --no-default-features` does not build
# examples.
#
# **This step is kept and is not redundant** (0.14.24, D-241, §14.1 C-4).
# The `test` job now *runs* the suite with metrics off, and C-4 was written
# as "a build gate, not a `cargo check`" — a phrasing that reads as a
# replacement. Measured before acting: `cargo test --no-default-features`
# builds the examples but NOT `benches/budgets.rs`, which is
# `harness = false`. A deliberate type error in that bench is invisible to
# the suite and caught here. Each gate sees something the other cannot, so
# this one stays — cheap, early, and in the fast job.
- name: check with metrics off
run: cargo check --no-default-features --all-targets
env:
RUSTFLAGS: -D warnings
# Rustdoc has its own warning set that `cargo clippy` never sees —
# unresolved intra-doc links, public docs linking to private items,
# unclosed HTML tags — and nothing in this workflow built the docs, so
# they accumulated silently: 18 of them by 0.8.0, every one pre-existing
# and none noticed at the commit that introduced it. A broken
# `[`Foo::bar`]` renders as literal brackets on docs.rs, which is a
# published defect in the crate's own documentation.
#
# `RUSTDOCFLAGS` rather than `RUSTFLAGS`, and scoped to this step for the
# same reason clippy's is (see above): as a workflow-level env it would
# also apply to the `cargo publish --dry-run` job, where a doc warning
# from a dependency would fail a packaging check for something the
# release does not control.
#
# `--no-deps` so only this crate's docs are built and only this crate's
# warnings can fail the gate. `--features metrics` matches what the test
# job documents; `property-tests` adds no library items, so unlike clippy
# there is nothing extra for it to reach here.
# The fuzz crate compiles against the crate's *public* surface, and it is
# a separate workspace no other gate builds (0.15.21, [D-263]).
#
# It was already built once per run — by the `fuzz` job below, after a
# nightly toolchain and a `cargo install cargo-fuzz --locked`. That is
# several minutes before a one-line type error is reported, and it is the
# only place the error could appear at all: D-255's `#[non_exhaustive]`
# sweep reached `NodeAttributes` and `MaterializedState`, which
# `fuzz/src/bin/seed.rs` builds by struct expression, and a struct
# expression for a `#[non_exhaustive]` type is refused only *outside* the
# defining crate. Every in-crate use kept compiling, so the local gates
# and this workflow's other jobs stayed green through nine releases.
#
# Stable and `check`: the three targets check clean without nightly, and
# a type error does not need a fuzzer to find. Locally:
# `python scripts/run_rust_suite.py --fuzz-check`.
- name: fuzz crate compiles (its own workspace)
working-directory: fuzz
run: cargo check --all-targets
- name: rustdoc (no broken links)
run: cargo doc --no-deps --features metrics
env:
RUSTDOCFLAGS: -D warnings
# **Report-only, deliberately.** This repo has never been rustfmt-clean —
# 246 diffs at 0.6.0, essentially all in tests and benches — so a blocking
# gate would go red on its first run for a pre-existing condition, and the
# only way to green it is a repo-wide reformat nobody reviewed. Adopting
# rustfmt is a decision worth taking on purpose: run `cargo fmt --all` in
# its own commit, then delete `continue-on-error` here.
- name: rustfmt (advisory)
run: cargo fmt --all -- --check
continue-on-error: true
# The README carries an MSRV badge and `Cargo.toml` a `rust-version`, and
# both are claims. Nothing else in the build would notice them going stale:
# the crate is developed on stable, so a newly used std method raises the real
# floor silently and the declared one stays where it was.
#
# `--all-targets` deliberately: tests and benches use `OnceLock` and other
# items the library does not, and a contributor hitting a build failure the
# badge said would not happen is the same defect as a wrong badge.
msrv:
name: MSRV (rust-version in Cargo.toml)
runs-on: ubuntu-latest
# one `cargo check` on the pinned MSRV; max observed 0.5 min.
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- id: msrv
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed 's/.*= *//; s/\"//g')" >> "$GITHUB_OUTPUT"
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ steps.msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-features --all-targets
# `README.md` promises "Windows desktop, Linux, or macOS", and until 0.8.0 the
# only macOS evidence this project held arrived *through pyo3* — `python.yml`
# added `macos-latest` at P7, so the crate that is the actual product was
# tested on two of the three platforms it claims. That is a strange shape for
# a README claim to rest on, and the fix is four characters.
#
# It also buys a measurement worth having on its own: **the R15 rate on Apple
# silicon is unknown.** Every figure in `.cargo/config.toml` and in the R15
# risk row is from this Windows machine, and the fault has never been observed
# on a non-Windows runner — which is not the same as knowing it is absent.
# A1's classifier is what makes that observable rather than merely noisy: a
# macOS crash will now say `CRASH` and name its target instead of arriving as
# a smaller green.
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# the suite on three runners. Windows is the long one, and the reason
# this number is not tighter: max observed 64.7 min, of which 51.9 was
# the quarantined step, which now carries its own cap below.
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
attempts: 3
- os: macos-latest
attempts: 3
# Eight on Windows, which is what the local release gate has always
# used (0.15.21, [D-263]). The first CI run this branch ever had
# crashed 3/3 on the main suite — `branch_storage_tests`,
# `wave1_regression_tests`, then `branch_read_tests` — a different
# target each time, zero named failures throughout: R15's signature
# exactly, and the reason `.cargo/config.toml` sets
# RUST_TEST_THREADS = "1" in the first place.
#
# This is not laundering a failure into a pass, for the reason the
# property step below states at length: the classifier returns FAILED
# on attempt 1 and never retries it, so a larger budget buys tolerance
# for CRASH and for nothing else. Three was inherited from the
# feature-off step, which runs on ubuntu where R15 has never been
# observed; it had never been checked against a Windows runner
# because, per [D-262], no CI run existed to check it against.
- os: windows-latest
attempts: 8
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# For `scripts/run_rust_suite.py`. Pinned rather than relying on the
# runner's default: `python` and `python3` are not the same name on both
# images, and a gate that only exists on one OS is not a gate.
- uses: actions/setup-python@v6
with:
python-version: "3.12"
# `.cargo/config.toml` sets RUST_TEST_THREADS = "1" for R15 and applies
# here automatically. Read that file before changing anything below.
#
# R15 is an intermittent libSQL access violation (0xC0000005) triggered by
# concurrent database opens. It kills the process, so the affected target
# never prints its `test result:` line while every other target still
# prints its own — the run comes back with a SMALLER PASS COUNT AND ZERO
# FAILURES, which reads as green to anything summing passes, and red to
# cargo's exit code, for a reason that has nothing to do with the change
# under test.
#
# Both steps below used to be `for attempt in 1 2 3`, which counts
# failures without reading them: three failures produced four lines of log
# and none of them said whether libSQL died or a property found a real
# defect. `scripts/run_rust_suite.py` classifies instead — CRASH, FAILED,
# INCOMPLETE, TEARDOWN, BUILD — and retries only CRASH. A genuine failure
# is now reported on attempt 1 with the test named. D-107 did this for the
# Python suite; D-110 brings it back to Rust.
# The classifier is verified before it is trusted, and this step exists
# because verifying it by injection was not enough. Both injections were
# run locally and both were correct; the first CI run then reported
# `BUILD` on a suite in which all 27 targets passed, because
# `CARGO_TERM_COLOR: always` (set above, and not set locally) wraps every
# cargo status line in SGR escapes and the stderr parse matched nothing.
#
# Injection proves the classifier reads a real cargo run. It cannot prove
# it reads a run shaped the way *this workflow* shapes one. The fixtures
# include the coloured shape, cost no compile, and run first so a broken
# classifier is reported as a broken classifier rather than as a broken
# suite.
- name: suite gate self-test
shell: bash
run: python scripts/run_rust_suite.py --self-test
- name: test (with R15 retry)
shell: bash
run: python scripts/run_rust_suite.py --features metrics --attempts ${{ matrix.attempts }}
# The generated-history binaries are quarantined behind a feature (see
# .cargo/config.toml): a property case opens a database of its own, which
# is exactly R15's trigger, so they fault far more often than the rest.
# Run as their own step so their noise cannot be mistaken for the suite's.
#
# Six attempts here, not three. The old budget was calibrated on the main
# suite — "R15 has always passed on re-run" is true where
# RUST_TEST_THREADS = "1" helps, and these targets are quarantined
# precisely because it does not: doctrine_property_tests alone measured
# 9 crashes in 15 runs at 0.7.0. Against p≈0.6 per attempt, three attempts
# go red about 22% of the time and six about 5% — which matches the 2-in-9
# observed on `main` in runs 30706318073 and 30706474231, both doc-only
# commits.
#
# p≈0.6 was one binary, one session, n=15. Measured on this step as a
# whole at 0.12.0 — 93% per attempt, n=100, on a developer Windows box
# under sustained load (.cargo/config.toml, D-147). Six attempts at that
# rate is 65%, and this step plainly does not go red 65% of the time
# here, so the runner is NOT that machine and the two figures bracket it
# rather than contradict each other: the 2-in-9 observed above implies
# p≈0.78 on the runner, between 0.6 and 0.93.
#
# The honest summary is that this rate is a property of the machine and
# the load, not a constant of the crate, and no measurement taken
# anywhere else predicts the runner. The budget is deliberately NOT
# raised: at any of these rates no budget makes the step a gate, and more
# attempts only buy more chances to launder a real failure. What the step
# is for is running these binaries at all and reporting honestly when the
# engine wins.
#
# Raising the budget was rejected while the loop existed, because more
# attempts also meant more chances to launder a real failure into a pass.
# The classifier removes that: a FAILED result returns on attempt 1 and is
# never retried, so the only thing six attempts buys is more tolerance for
# the one outcome that is known to be noise.
#
# **`continue-on-error`, and it is not tolerance — it is the quarantine
# decision reaching this file** (0.14.19, D-236). `Cargo.toml` has said
# since 0.8.0 that `doctrine_property_tests` "still faults often enough
# serialised to be unusable as a gate on Windows", and that is the whole
# reason the feature exists. This step then blocked anyway: it is in the
# `test` job, `release.yml`'s `verify` calls this workflow, and `publish`
# needs `verify` — so an engine fault decided whether a release reached
# crates.io. v0.14.0 took two `gh run rerun --failed` cycles to publish a
# tree nothing was wrong with.
#
# The argument is not the runner-hours. At 65% red on a HEALTHY tree there
# is no contrast between this step's healthy and unhealthy output, so
# blocking on it does not verify the tree, it **selects** the runs where
# the coin landed heads. `public-api`'s comment above refuses
# `continue-on-error` for the opposite and correct reason: that gate can
# pass, and it distinguishes "moved" from "could not be measured" itself.
#
# What is given up, stated rather than implied: a publish can now ship
# with **zero completed property runs** for its SHA, and a genuine FAILED
# here no longer blocks either. The mitigation is a human read, which is
# what the classifier's three-state verdict below is for, and it is on the
# release checklist under D-234. R15's signature is teardown-shaped — the
# assertions report `ok` and the process dies at exit — so even a crashed
# attempt usually carries the verdict, and "zero named failures" is a
# receipt rather than an apology.
#
# Not silenced and not abandoned: the step still runs, on every push and
# every pull request, and reports. If release cadence thins after 1.0 this
# needs a scheduled home rather than a drawer (D-236's follow-up); the
# real exit is W1.3 upstream. If it ever returns to a blocking path the
# shape is a **crash-aware gate** — nonzero only on named failures — and
# `run_rust_suite.py` already classifies well enough to build it, which is
# why its exit codes are left exactly as they were.
# **The feature-off configuration is now run, not only compiled** (0.14.24,
# D-241, §14.1 C-4). `cargo check --no-default-features --all-targets` in
# the `lint` job proves this configuration *compiles*; nothing anywhere
# proved its 613 tests pass, while `README.md` published that count and
# [D-154](../../docs/architecture/s13-decision-register.md#d-154) said in
# writing that the path "is still built and tested". That is D-169's class
# one level up: a document publishing a fact about a configuration nobody
# runs.
#
# The gap is not hypothetical and was measured rather than argued.
# `MICROS_CEILING` is deliberately kept OUT of the `metrics` cfg —
# `src/metrics.rs` says why: "a build that does not record is exactly the
# build where nobody would notice it break." Widening it to `1 << 57`, so
# the packed duration overflows the kind bits, leaves
# `cargo check --no-default-features --all-targets` green and fails
# `metrics::tests::the_packing_leaves_room_for_both_fields` here. The
# crate already stated the intent that this run with the counters off;
# only CI had not.
#
# Ubuntu only. The feature switch is not OS-dependent — the matrix above
# exists for libSQL's platform behaviour, and R15 is a Windows fault —
# so a second full suite on all three would triple the cost of this job to
# re-answer the same question. Measured while writing this step: five runs
# of it on a developer Windows box gave four `completed` (613 passed) and
# one `crashed-R15` at 3/3 attempts, zero named failures throughout. On
# Windows this would be a step that goes red on a healthy tree often
# enough to fail D-236's test; on ubuntu it is a gate. Blocking, not `continue-on-error`: unlike
# the property step below, this one passes on a healthy tree, which is the
# test D-236 sets for whether a gate is a gate.
- name: test with metrics off (ubuntu only)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: python scripts/run_rust_suite.py --no-default-features --attempts 3
# **The cap is on the step and not only on the job, and that is the
# whole design** (0.15.25, [D-267]).
#
# [D-236] gave this step the right to fail: it is red on a healthy tree
# roughly two runs in three on Windows, so `continue-on-error` keeps its
# exit code away from the job. Nobody gave it the right to run for six
# hours. That permission arrived from the default job cap above, which is
# a number no one here set.
#
# A job cap alone would not preserve the quarantine. When the platform
# stops a job at its limit the job is **cancelled**, and a cancelled job
# is not a failed step -- `continue-on-error` never engages, and
# `release.yml`'s `verify`, which calls this workflow whole, would see a
# failed run rather than a step that was allowed to fail. A **step**
# timeout produces a step failure, which is exactly what the quarantine
# already absorbs. So this fires first, and the job cap is the backstop
# rather than the mechanism.
#
# 75 minutes, from measurement. Across the twelve runs before this commit
# the step took 17.6 / 35.9 / 51.9 minutes on Windows (min / median / max)
# and 1.7 to 3.7 elsewhere. The bound is deliberately not "one clean pass
# plus slack": on Windows the R15 retries **are** the healthy case, and
# the 51.9-minute run was one that passed, so a tighter number would cut
# runs that work today. This sits about 45% above the worst healthy run
# and at about a fifth of the default it replaces.
#
# It also bounds a question it does not answer. Six attempts against a
# suite known to crash on this platform for a reason outside this
# repository is what produces a 52-minute step, and whether the sixth
# attempt buys signal or lottery tickets is worth asking on its own. The
# cap limits what the answer being "no" can cost while that question
# waits.
- name: property tests (quarantined, R15-prone, non-blocking)
shell: bash
continue-on-error: true
timeout-minutes: 75
run: python scripts/run_rust_suite.py --features property-tests --attempts 6
# The snapshot loader's fuzz targets (0.13.14, W8.4, D-187).
#
# This job is where W8.4 actually runs. `cargo-fuzz` needs nightly and
# libFuzzer and does not support Windows, so it exists nowhere in the local
# development loop -- which is exactly why the deterministic half of the item
# lives in `src/temporal/snapshot.rs`'s unit tests, where every `cargo test`
# on every platform runs it. This half is the part a fixed set of cases
# cannot do: exploring inputs nobody thought of.
#
# Time-boxed rather than open-ended. A fuzzing session has no natural end, and
# a PR gate that never finishes is a gate that gets removed; 90 seconds a
# target finds shallow regressions against a seeded corpus and keeps the job
# inside the noise of the rest of CI. Finding deep bugs is what a long
# out-of-band session is for, and the corpus this job seeds is the same one it
# would start from.
#
# The limits are the assertion, not a safety margin -- but they are TWO
# DIFFERENT ASSERTIONS, and this job conflated them until v0.14.0 went red.
#
# `-malloc_limit_mb` is the gate. It bounds a SINGLE allocation, which is
# exactly the clause the plan states -- "never an allocation storm" -- and the
# reason W8.2 bounded the reader at all. A quarter-gigabyte request out of a
# 6 KB input is a finding, and libFuzzer reports it with the offending input
# attached. Unchanged at 256 MiB, deliberately.
#
# `-rss_limit_mb` bounds the WHOLE PROCESS, and the process is not the loader.
# At 256 MiB it failed the release run for v0.14.0 and, because `release.yml`
# gates the upload on this workflow, skipped the crates.io publish entirely.
# The report says what happened, once it is read past the first line:
#
# ERROR: libFuzzer: out-of-memory (used: 259Mb; limit: 256Mb)
# Live Heap Allocations: 24,732,141 bytes in 759 chunks;
# quarantined: 202,052,055 bytes in 260,170 chunks
#
# Live is 24 MiB. **202 MiB of the 259 is AddressSanitizer's free-quarantine**
# -- memory ASan holds back after `free` so a later use-after-free is still
# diagnosable -- accumulated across 570,597 executions. Three more details
# rule out a defect in the loader: the failing input was the EMPTY one
# (`Debug: []`, artifact `oom-da39a3ee5e6b4b0d3255bfef95601890afd80709`, which
# is SHA-1 of zero bytes), so no input caused it; RSS sat flat at 231 MiB from
# execution 65,536 to 448,293 and moved only when new coverage reached new
# functions, which is bookkeeping growth and not a leak; and the top live
# allocation is reached through `libstdc++`, i.e. libFuzzer's own corpus
# machinery rather than anything in this crate.
#
# What the old limit actually measured, then, is HOW MANY CASES THE RUNNER GOT
# THROUGH IN 90 SECONDS. A faster runner fails it sooner -- the opposite of
# what a fuzz gate should reward -- and that is why it survived every PR that
# preceded the release and then blocked the release.
#
# 2048 MiB is a backstop against a genuine runaway taking down a 16 GiB
# runner. It is not a claim about the loader; the malloc limit is the claim
# about the loader, and it is the one that did not move.
fuzz:
name: fuzz (snapshot loader and the small parsers)
runs-on: ubuntu-latest
# nightly toolchain, cargo-fuzz install, seven targets; max observed 9.0 min.
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
# Two Cargo workspaces: the crate, and the fuzz crate that deliberately
# is not a member of it.
workspaces: |
. -> target
fuzz -> fuzz/target
- name: install cargo-fuzz
run: cargo install cargo-fuzz --locked
# Generated, never committed: a corpus of valid v3 files goes stale the
# moment SNAP_FORMAT_VERSION moves, and a stale corpus still looks like a
# corpus. See `fuzz/src/bin/seed.rs`.
- name: seed the corpus
working-directory: fuzz
run: cargo run --bin seed
- name: snapshot_container
run: cargo fuzz run snapshot_container -- -max_total_time=90 -rss_limit_mb=2048 -malloc_limit_mb=256
- name: snapshot_payload
run: cargo fuzz run snapshot_payload -- -max_total_time=90 -rss_limit_mb=2048 -malloc_limit_mb=256
- name: snapshot_frame
run: cargo fuzz run snapshot_frame -- -max_total_time=90 -rss_limit_mb=2048 -malloc_limit_mb=256
# The four small parsers the review's A-5 names (0.15.24, [D-266]). Each
# takes text a caller supplied and turns it into something the ledger
# keeps: a stamp, a MATCH expression, a log key, a lineage name.
#
# 30 s and not 90: these are pure functions over a short string, with no
# container format to discover and no decompression to bound, so a run
# saturates what it is going to reach far sooner than the snapshot
# targets do. Nothing here can allocate on the input's say-so either,
# which is why the memory limits the snapshot targets carry are absent --
# `-malloc_limit_mb` on a function that allocates one String per call
# would be checking a bound nobody could exceed.
#
# They need no corpus seeding: the seed generator above builds valid
# snapshot containers, and there is no analogous "valid" starting point
# here that libFuzzer cannot find in the first second by itself.
- name: timestamp_parse
run: cargo fuzz run timestamp_parse -- -max_total_time=30 -rss_limit_mb=2048
- name: fts5_escape
run: cargo fuzz run fts5_escape -- -max_total_time=30 -rss_limit_mb=2048
- name: validate_id
run: cargo fuzz run validate_id -- -max_total_time=30 -rss_limit_mb=2048
- name: branch_id
run: cargo fuzz run branch_id -- -max_total_time=30 -rss_limit_mb=2048
# A crash is the whole output of this job, and libFuzzer writes it to a
# file rather than to the log. Without this the run says "the fuzzer
# failed" and the input that caused it is gone with the runner.
- name: upload crash artifacts
if: failure()
uses: actions/upload-artifact@v6
with:
name: fuzz-artifacts
path: fuzz/artifacts/
if-no-files-found: ignore
# The public API against its checked-in baseline (0.13.32, W11.2, D-205).
#
# Nightly, for the same reason `fuzz` is: `cargo-public-api` reads rustdoc's
# JSON output, which is unstable. That makes this the second job here that
# cannot run in the local stable loop, and the reason the baseline is a file
# in the repository rather than a number in someone's head.
#
# **Blocking on the result, immune to the noise, and the script is what tells
# them apart.** `check_public_api.py` exits 1 when the surface moved and 2
# when it could not be measured -- nightly missing, or a rustdoc JSON format
# change that `cargo-public-api` has not caught up with yet. Collapsing those
# two into "the job is red" is what `continue-on-error` would do, and a job
# that is allowed to be red is a job nobody reads; collapsing them into "the
# job is green" would be worse. So 2 is a warning that is explicitly not a
# pass, and 1 fails. This is `run_rust_suite.py`'s design applied to a
# different flavour of the same problem (D-147).
public-api:
name: public API baseline
runs-on: ubuntu-latest
# cargo-public-api install and one diff; max observed 2.2 min.
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- name: install cargo-public-api
run: cargo install cargo-public-api --locked
- name: diff against the baseline
shell: bash
run: |
set +e
python scripts/check_public_api.py
code=$?
set -e
if [ "$code" = "2" ]; then
echo "::warning::the public API could not be measured; this is not a pass"
exit 0
fi
exit $code
# A `cargo publish` failure after a tag is pushed is expensive: the tag is
# already public and the version number is spent. This runs the same
# packaging and verification step on every PR, so the release job's only new
# variable is the upload itself.
package:
name: cargo publish --dry-run
runs-on: ubuntu-latest
# `cargo publish --dry-run`; max observed 1.1 min.
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo publish --dry-run