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
name: Rust CI
on:
push:
branches:
# No base-branch filter on pull_request: stacked PRs (base = another feature
# branch) must get the same gate as PRs to main — the Windows jobs
# especially, since a local Linux gate can't catch cfg(windows) breakage.
pull_request:
# Beta and nightly run here rather than on every PR (see the `rust` matrix).
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
# One in-flight run per ref. Pushing a fixup used to leave the superseded run
# compiling to completion on every leg, producing a verdict on a commit nobody
# would read it for — while holding the runners the new run then queues behind.
concurrency:
# `event_name` is in the key so the nightly schedule and a push to main do not
# share a group and serialize behind each other.
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
# Never cancel a main-branch run: those are the ones a release is cut from.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# ~90% of a CI leg is compilation, not testing: the Windows leg spent ~295s
# compiling and 35s running all 2046 tests. So the compile knobs below are
# where the wall clock is. Measured together on a cold target dir, building
# exactly what CI builds: compile 172s -> 144s (-16%), and the target dir
# 8473 MB -> 2616 MB (-69%), which is what rust-cache has to restore.
#
# Incremental compilation is pure overhead here: it exists to make the SECOND
# build in a working tree fast, and CI never gets one. It costs time and is
# most of the target-dir bloat.
CARGO_INCREMENTAL: 0
# Full debuginfo (cargo's default, `debug = 2`) is the expensive half of a
# debug build, and on Windows the PDB write dominates linking — 12
# integration-test binaries each link the whole lib. `line-tables-only` keeps
# what a CI failure is actually read for: backtraces with file:line, paired
# with the RUST_BACKTRACE above. What it drops is variable inspection, which
# needs an interactive debugger nobody attaches to a runner.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
jobs:
# Format checking
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
components: rustfmt
- name: Check formatting
run: cargo fmt -- --check
# Linting with Clippy — `-D warnings` makes lint regressions fail CI.
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Run clippy
# `--workspace` so the `mermaid-runtime` crate (safety classifier,
# approvals store) is linted too — a bare `cargo clippy` only covers the
# root package and silently skipped it.
run: cargo clippy --workspace --all-targets -- -D warnings
# Tier-2 lint debt: pedantic + nursery + a handful of named lints, tracked
# against `.github/baselines/clippy_pedantic.txt` rather than blocking.
#
# NOT on pull requests, and the reason is measurable: enabling these lints
# changes clippy's fingerprint, so this job cannot share the `Clippy` job's
# cache and rebuilds the workspace (~200s). "How much pedantic debt is there"
# does not change PR-to-PR in a way a merge should wait on. Same trade the
# `test` matrix already makes for its beta and nightly legs: the same signal,
# arriving within a day, off the critical path.
clippy-ratchet:
name: Lint Debt (pedantic + nursery)
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
# Its own key: the lint set makes these artifacts useless to the
# blocking `Clippy` job, and sharing one key would have each evict
# the other's cache on every run.
key: clippy-ratchet
- name: Pedantic + nursery debt only shrinks
run: python3 .github/scripts/check_clippy_ratchet.py
# Build and test on multiple Rust versions and OS
test:
name: Test Suite
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
# A PR gets all three platforms on stable — those are the required
# checks, and the merge waits on the slowest of them.
#
# Beta and nightly answer a different question ("will a future toolchain
# break us?"), and the answer does not change between one PR and the
# next. Running them per-PR spent 4 of 7 test jobs re-asking it, so they
# run on the nightly schedule and on every push to main instead: the
# same signal, arriving within a day, off the critical path.
rust: ${{ fromJSON(github.event_name == 'pull_request' && '["stable"]' || '["stable", "beta", "nightly"]') }}
# Nightly stays Linux-only — enough to catch a future-toolchain break
# without tripling the cost of finding it. On a PR the list above has no
# nightly to exclude and these are no-ops.
exclude:
- os: macos-latest
rust: nightly
- os: windows-latest
rust: nightly
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: ${{ matrix.rust }}
# Cache dependencies for faster builds
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
# Cache key includes OS and Rust version
key: ${{ matrix.os }}-${{ matrix.rust }}
- name: Install cargo-nextest
uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10
with:
tool: nextest
# No separate `cargo build --workspace` step before this one. It looked
# free — surely nextest reuses the artifacts — but it measured 144s cold
# against 113s for nextest alone, because the two invocations do not want
# the same lib: `build` produces a plain rlib and the bins, nextest wants
# the lib again under `cfg(test)`. The integration tests reach the bins
# through `env!("CARGO_BIN_EXE_mermaid")`, which makes cargo build them
# here anyway, so nothing was lost by dropping the step — a compile error
# still fails this step, just with nextest's name on it.
- name: Build and run tests
# nextest runs the same test binaries as `cargo test`, but applies the
# `.config/nextest.toml` retry policy that auto-heals the documented
# Windows cancellation flake. `--workspace` covers the mermaid-runtime
# crate (safety classifier, approvals store). No doctests exist, so
# nextest skipping them loses nothing.
run: cargo nextest run --workspace
# The insta snapshot suite now runs on every matrix leg (#296), so a
# mismatch is reproducible locally. Still publish the `.snap.new` siblings
# insta writes on failure: the frame a leg you don't have rendered is what
# makes a platform-specific diff reviewable (and appliable) at all.
- name: Upload pending snapshots
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pending-snapshots-${{ matrix.os }}-${{ matrix.rust }}
path: '**/*.snap.new'
if-no-files-found: ignore
retention-days: 3
# Only check documentation on stable Linux
- name: Check documentation
if: matrix.rust == 'stable' && matrix.os == 'ubuntu-latest'
# `-D warnings` is what makes this step mean something. Without it
# rustdoc prints broken intra-doc links and exits 0, so a rename that
# orphaned a `[`Foo::bar`]` has always passed silently. It found 20 on
# its first run, including `TaskBroker::note_tokens` — a method renamed
# to `add_tokens` with the docs left pointing at the old name. This
# codebase leans hard on intra-doc links (every module has a `//!`
# header), so they are exactly what rots on a refactor. Same reasoning
# as the `-D warnings` on the clippy job.
#
# `private_intra_doc_links` is allowed, and only because of the
# `--document-private-items` on the very next line: that flag is what
# this job builds, so a public doc linking to a private item renders a
# working link here. The lint is about a doc set that omits private
# items, which is not the one being produced.
env:
RUSTDOCFLAGS: -D warnings -A rustdoc::private_intra_doc_links
run: cargo doc --no-deps --document-private-items
# Dependency hygiene: advisories, licenses, bans, sources, and deps declared
# but never imported. Reads manifests and Cargo.lock; compiles nothing.
#
# The job keeps the id `security` and the name "Security Audit" even though
# its scope is now wider than advisories: "Security Audit" is a REQUIRED
# status check on `main`. Renaming it does not fail the gate, it deletes the
# gate — the required context stops reporting and every PR waits forever on a
# check that no longer exists. Rename the branch-protection context first.
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# `rustsec/audit-check` compiled cargo-audit from source on every run:
# 221s in a single step, on a job whose actual work is reading
# Cargo.lock against an advisory database. That made it 94% as long as
# the Windows leg — the critical path — for no reason a prebuilt binary
# doesn't solve. `taiki-e/install-action` downloads one (the same action
# already used for nextest).
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10
with:
tool: cargo-deny,cargo-machete
# `cargo deny` replaced `cargo audit`. It reads the same RustSec
# database, so the security verdict is unchanged — `deny.toml` sets
# `unmaintained = "workspace"`, which preserves the judgement the old
# step encoded by declining `--deny warnings`. What it adds is the three
# questions `audit` cannot ask: license compatibility (a transitive GPL
# dep in a statically linked `MIT OR Apache-2.0` binary is a licensing
# incident, and nothing looked), bans (`openssl-sys` must never reappear
# in a deliberately rustls-only build; `wildcards = "deny"` stops
# `foo = "*"`), and source provenance. The rationale for each is in
# `deny.toml`.
- name: Advisories, licenses, bans, sources
run: cargo deny check
# Declared-but-never-imported dependencies. Not cosmetic here: the root
# manifest carried `keyring` and `dbus-secret-service` while every call
# site lived in `mermaid-model`, and workspace feature unification kept
# `cargo build` green — the same class of gap that half-released v0.21.0
# from the other direction.
- name: Unused dependencies
run: cargo machete
# Every publishable crate must build ALONE, on Linux.
#
# `cargo build --workspace` unifies features across members, so the root
# enabling `dbus-secret-service/vendored` made every crate's keyring build —
# including one whose own manifest never declared it. Nothing caught that
# until `cargo publish` verified the packaged tarball in isolation, mid-release,
# after `mermaid-runtime` had already gone to crates.io and could not be taken
# back (v0.21.0). A local `--dry-run` could not have caught it either: on
# Windows keyring uses `windows-native` and never touches dbus.
#
# `cargo package` + building the extracted tarball reproduces exactly what
# publish verifies, without needing the version to exist on the index — so
# this runs on every PR instead of once per release.
isolated-crate-build:
name: Crates build standalone (Linux)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: isolated-crate-build
- name: Build each crate outside the workspace
run: |
set -euo pipefail
# Copy the crates somewhere with no workspace root above them. Each
# then builds as its own root — no feature unification with
# `mermaid-cli` — which is exactly the isolation `cargo publish`
# applies, and exactly what hid the missing dep.
#
# Copying rather than `cargo package`: packaging resolves deps against
# the crates.io index, so a crate depending on an unreleased sibling
# version cannot be packaged before that sibling is published. That
# made the first version of this job fail on its own fix PR. Sibling
# path deps resolve fine here because the siblings are copied too.
ISO="$RUNNER_TEMP/isolated"
mkdir -p "$ISO"
cp -r crates/. "$ISO/"
for crate in mermaid-runtime mermaid-model mermaid-domain; do
echo "::group::$crate (standalone)"
( cd "$ISO/$crate" && cargo build --all-targets )
echo "::endgroup::"
done
# The root crate must compile in `--release`, which is a different set of
# source files than any other job compiles.
#
# `[lints.rust] warnings = "deny"` applies to both profiles, but every other
# job builds debug: `clippy --all-targets`, `nextest`, and the standalone
# crate builds above. So `#[cfg(debug_assertions)]` items exist everywhere CI
# looks, and anything whose only consumer is one of them reads as live code.
# In `--release` the consumer is stripped and the item becomes `dead_code`,
# which `deny` turns into a build failure.
#
# That is not hypothetical: v0.22.0 was tagged with `HashWrite` in
# `render/widgets/chat.rs` constructed only by a `#[cfg(debug_assertions)]`
# fingerprint helper. Every check on the PR and on `main` was green, and all
# five platform builds then failed on the tag — after the version bump had
# already merged. Nothing published, because the release job depends on the
# builds, but the tag had to be moved.
#
# `--release` and not `--profile release --all-targets`: this mirrors what
# `release.yml` actually runs for the shipped binaries.
release-build:
name: Release Build (Linux)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: release-build
- name: Build the workspace in release
run: cargo build --workspace --release
# Dependency-free source guards: no emoji in user-facing output; the pure
# `src/domain` / `src/render` / `src/prompts.rs` layers only reach downward
# and stay free of I/O and the wall clock.
#
# The layering guard ratchets against `.github/baselines/layering.txt`, which
# may only shrink — see `.github/scripts/ratchet.py`.
guards:
name: Source Guards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: No emoji in source
run: python3 .github/scripts/check_no_emoji.py
- name: Layering + purity (domain, render, prompts)
run: python3 .github/scripts/check_layering.py
- name: Lint-suppression budget
run: python3 .github/scripts/check_expect_budget.py
- name: Crate-root re-exports have consumers
run: python3 .github/scripts/check_exports.py
# Heavier integration coverage that CI's default suite skips: the daemon
# `#[ignore]`d tests (spawn a real mermaidd) and the deterministic, model-free
# `mermaid self-test`. Linux-only — enough to catch daemon/self-test breakage.
integration:
name: Daemon + Self-Test (Linux)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Build binaries
run: cargo build --workspace
- name: Daemon integration tests (ignored)
run: cargo test --test daemon_integration -- --ignored
- name: Sandbox network kill-switch (ignored, Linux)
run: cargo test --test sandbox_network -- --ignored
- name: Sandbox filesystem confinement (ignored, Linux)
run: cargo test --test sandbox_fs -- --ignored
- name: Self-test (deterministic, model-free)
run: cargo run --bin mermaid -- self-test --format json
# The managed-search test downloads the pinned bundle, starts the real
# Granian/SearXNG process, performs a JSON search, and verifies shutdown. Run
# it on every supported managed-search OS; the normal Windows matrix covers
# the actionable unsupported-backend path.
managed-search-integration:
name: Managed SearXNG (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: managed-searxng-${{ matrix.os }}
- name: Managed SearXNG end-to-end (ignored)
run: cargo test --lib searxng::tests::managed_searxng_end_to_end -- --ignored --exact
# OS-sandbox enforcement on the non-Linux platforms with a real backend
# (the Linux `integration` job above already runs these tests). macOS-only
# for now; the Windows AppContainer port adds windows-latest. The
# `#[ignore]`d tests include the Seatbelt profile-compile canary, so an SBPL
# grammar change in a new macOS release fails this job loudly.
# Real macOS verification of the clipboard FILE-REFERENCE paste path.
#
# This path (Finder 'Copy' on an image file, rather than copying the image
# itself) was written against documented `public.file-url` behavior and could
# not be exercised by its author, who has no Mac. The Linux equivalents are
# verified by hand; a CI runner is the only Apple hardware this project has,
# so the check lives here rather than in a paragraph nobody re-reads.
clipboard-macos:
name: Clipboard file-reference paste (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: clipboard-macos
- name: Arm the clipboard with a file reference, then probe it
run: |
set -uo pipefail
DIR="$RUNNER_TEMP/mermaid-fileref"
mkdir -p "$DIR"
# A space in the name so the URI carries a percent-escape.
echo 'not-a-real-png-but-the-extension-is-what-matters' > "$DIR/My Shot.png"
# Exactly what Finder's Copy puts on the pasteboard: a file URL.
if ! osascript -e "set the clipboard to POSIX file \"$DIR/My Shot.png\""; then
echo "::notice::this runner cannot drive the pasteboard; skipping"
exit 0
fi
INFO=$(osascript -e 'clipboard info' || true)
echo "clipboard info: $INFO"
case "$INFO" in
*furl*) ;;
*)
echo "::notice::pasteboard did not take a file reference; skipping"
exit 0
;;
esac
# Only now is the assertion meaningful: the pasteboard really holds a
# file reference, so a failure here is mermaid's probe, not the runner.
cargo test --lib manual_file_reference_paste -- --ignored --nocapture
sandbox-integration:
name: OS Sandbox
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: sandbox-${{ matrix.os }}
- name: Build binaries
run: cargo build --workspace
- name: Sandbox network denial (ignored)
run: cargo test --test sandbox_network -- --ignored
- name: Sandbox filesystem confinement (ignored)
run: cargo test --test sandbox_fs -- --ignored
- name: Self-test (real sandbox availability probes)
run: cargo run --bin mermaid -- self-test --format json