rust-web-server 18.23.0

Single-binary web server and reverse proxy: automatic HTTPS (Let's Encrypt), HTTP/1.1, HTTP/2 and HTTP/3, load balancing, auth and rate limiting, configured in one TOML file. Also a Rust library: routing, middleware, MCP server, ORM, background jobs. No hyper, no Tower; HTTP/2 and HTTP/3 framing via h2 and quinn.
Documentation
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
name: CI

on:
  push:
    # Release lines (18.20.x, …) get the same checks as main.
    branches: [main, "[0-9]+.[0-9]+.x"]
  pull_request:
  schedule:
    # Weekly, so a newly published RustSec/npm advisory turns the badge red
    # even when nothing was pushed.
    - cron: "0 6 * * 1"

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always

jobs:
  test:
    name: test (${{ matrix.name }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: default (http3)
            args: ""
          - name: http1 only
            args: --no-default-features --features http1
          - name: tls + auth + sso + serde
            args: --features "sso sso-server sso-saml auth-asymmetric http-client acme serde macros openapi tower gateway-api model-sqlite storage-local storage-s3 storage-azure storage-gcs"
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.name }}
      - run: cargo test ${{ matrix.args }}

  msrv:
    name: msrv (1.99, all features)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@1.99
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --all-features

  # FIPS 140-3 build (aws-lc's validated module; the runner has CMake and Go).
  fips:
    name: fips (aws-lc FIPS module)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          key: fips
      - run: "cargo test --lib --features fips tls::"
      - run: cargo build --bin rws --features "fips acme auth sso"
      - run: ci/e2e/fips.sh target/debug/rws

  # TLS keys on a PKCS#11 token (SoftHSM2).
  pkcs11:
    name: pkcs11 (SoftHSM2)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          key: pkcs11
      - run: sudo apt-get update -q && sudo apt-get install -y -q softhsm2
      - run: cargo test --lib --features pkcs11 pkcs11
      - run: cargo build --bin rws --features pkcs11
      - run: ci/e2e/pkcs11.sh target/debug/rws

  # End-to-end checks against real external implementations — see ci/e2e/.
  e2e:
    name: e2e (${{ matrix.check }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        check: [acme-pebble, acme-dns01, acme-dns-api, acme-shared, acme-on-demand, h2spec, slow-clients, config-reload, access-log, proxy-policies, mirror, early-hints, dictionary, h3-upstream, authz, mcp-gateway, llm-proxy, admin, admin-ui, wasm-plugins, wasm-shim, systemd, mtls-crl, tls-internal, fastcgi, oidc, ws-upgrade, cluster, waf, fleet, native-histograms, otlp, grpc, proxy-protocol, realtime, openapi, tower, starters]
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          key: e2e
      - run: cargo build --bin rws --features "acme auth sso mimalloc storage-s3"
      - name: ACME against Pebble (Let's Encrypt's test CA)
        if: matrix.check == 'acme-pebble'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          mkdir -p "$RUNNER_TEMP/pebble/src"
          gh release download v2.10.1 --repo letsencrypt/pebble --pattern 'pebble-linux-amd64.tar.gz' --dir "$RUNNER_TEMP/pebble"
          tar xzf "$RUNNER_TEMP/pebble/pebble-linux-amd64.tar.gz" -C "$RUNNER_TEMP/pebble"
          gh api repos/letsencrypt/pebble/tarball/v2.10.1 | tar xz -C "$RUNNER_TEMP/pebble/src" --strip-components=1
          # The release archive ships the binary without the execute bit.
          pebble_bin="$RUNNER_TEMP/pebble/pebble-linux-amd64/linux/amd64/pebble"
          chmod +x "$pebble_bin"
          ci/e2e/acme_pebble.sh target/debug/rws "$pebble_bin" "$RUNNER_TEMP/pebble/src"
      - name: ACME DNS-01 against Pebble and BIND (RFC 2136 + TSIG, wildcard, exec hook)
        if: matrix.check == 'acme-dns01'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q bind9 bind9-dnsutils
          mkdir -p "$RUNNER_TEMP/pebble/src"
          gh release download v2.10.1 --repo letsencrypt/pebble --pattern 'pebble-linux-amd64.tar.gz' --dir "$RUNNER_TEMP/pebble"
          tar xzf "$RUNNER_TEMP/pebble/pebble-linux-amd64.tar.gz" -C "$RUNNER_TEMP/pebble"
          gh api repos/letsencrypt/pebble/tarball/v2.10.1 | tar xz -C "$RUNNER_TEMP/pebble/src" --strip-components=1
          pebble_bin="$RUNNER_TEMP/pebble/pebble-linux-amd64/linux/amd64/pebble"
          chmod +x "$pebble_bin"
          ci/e2e/acme_dns01.sh target/debug/rws "$pebble_bin" "$RUNNER_TEMP/pebble/src" "$(command -v named)" "$(command -v nsupdate)"
      - name: ACME DNS-01 through Cloudflare and Route 53 APIs (mocked into BIND), and DNS-PERSIST-01
        if: matrix.check == 'acme-dns-api'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q bind9 bind9-dnsutils
          mkdir -p "$RUNNER_TEMP/pebble/src"
          gh release download v2.10.1 --repo letsencrypt/pebble --pattern 'pebble-linux-amd64.tar.gz' --dir "$RUNNER_TEMP/pebble"
          tar xzf "$RUNNER_TEMP/pebble/pebble-linux-amd64.tar.gz" -C "$RUNNER_TEMP/pebble"
          gh api repos/letsencrypt/pebble/tarball/v2.10.1 | tar xz -C "$RUNNER_TEMP/pebble/src" --strip-components=1
          pebble_bin="$RUNNER_TEMP/pebble/pebble-linux-amd64/linux/amd64/pebble"
          chmod +x "$pebble_bin"
          ci/e2e/acme_dns_api.sh target/debug/rws "$pebble_bin" "$RUNNER_TEMP/pebble/src" "$(command -v named)" "$(command -v nsupdate)"
      - name: ACME with two replicas sharing certificates through Redis
        if: matrix.check == 'acme-shared'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q redis-server
          mkdir -p "$RUNNER_TEMP/pebble/src"
          gh release download v2.10.1 --repo letsencrypt/pebble --pattern 'pebble-linux-amd64.tar.gz' --dir "$RUNNER_TEMP/pebble"
          tar xzf "$RUNNER_TEMP/pebble/pebble-linux-amd64.tar.gz" -C "$RUNNER_TEMP/pebble"
          gh api repos/letsencrypt/pebble/tarball/v2.10.1 | tar xz -C "$RUNNER_TEMP/pebble/src" --strip-components=1
          pebble_bin="$RUNNER_TEMP/pebble/pebble-linux-amd64/linux/amd64/pebble"
          chmod +x "$pebble_bin"
          ci/e2e/acme_shared.sh target/debug/rws "$pebble_bin" "$RUNNER_TEMP/pebble/src" "$(command -v redis-server)"
      - name: On-demand TLS against Pebble and BIND
        if: matrix.check == 'acme-on-demand'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q bind9
          mkdir -p "$RUNNER_TEMP/pebble/src"
          gh release download v2.10.1 --repo letsencrypt/pebble --pattern 'pebble-linux-amd64.tar.gz' --dir "$RUNNER_TEMP/pebble"
          tar xzf "$RUNNER_TEMP/pebble/pebble-linux-amd64.tar.gz" -C "$RUNNER_TEMP/pebble"
          gh api repos/letsencrypt/pebble/tarball/v2.10.1 | tar xz -C "$RUNNER_TEMP/pebble/src" --strip-components=1
          pebble_bin="$RUNNER_TEMP/pebble/pebble-linux-amd64/linux/amd64/pebble"
          chmod +x "$pebble_bin"
          ci/e2e/acme_on_demand.sh target/debug/rws "$pebble_bin" "$RUNNER_TEMP/pebble/src" "$(command -v named)"
      - name: HTTP/2 conformance (h2spec)
        if: matrix.check == 'h2spec'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download v2.6.0 --repo summerwind/h2spec --pattern 'h2spec_linux_amd64.tar.gz' --dir "$RUNNER_TEMP"
          tar xzf "$RUNNER_TEMP/h2spec_linux_amd64.tar.gz" -C "$RUNNER_TEMP"
          ci/e2e/h2spec.sh target/debug/rws "$RUNNER_TEMP/h2spec"
      - name: Idle / slow clients can't starve the server
        if: matrix.check == 'slow-clients'
        run: ci/e2e/slow_clients.sh target/debug/rws
      - name: rws --check and SIGHUP route reload
        if: matrix.check == 'config-reload'
        run: ci/e2e/config_reload.sh target/debug/rws
      - name: Every config the site's playground can build passes rws --check
        if: matrix.check == 'config-reload'
        run: node site/scripts/check-playground.js target/debug/rws
      - name: Access log to a file (SIGUSR1 reopen, size rotation)
        if: matrix.check == 'access-log'
        run: ci/e2e/access_log.sh target/debug/rws
      - name: Upstream weights, stickiness, retries, circuit breaker, metrics
        if: matrix.check == 'proxy-policies'
        run: ci/e2e/proxy_policies.sh target/debug/rws
      - name: Request mirroring from the config file (copies, share, slow and down mirrors)
        if: matrix.check == 'mirror'
        run: ci/e2e/mirror.sh target/debug/rws
      - name: Relationship-based authorization from the config file (JWT callers, groups, folders, reload)
        if: matrix.check == 'authz'
        run: ci/e2e/authz.sh target/debug/rws
      - name: HTTP/3 upstreams (rws to rws over QUIC, TCP fallback)
        if: matrix.check == 'h3-upstream'
        run: ci/e2e/h3_upstream.sh target/debug/rws
      - name: 103 Early Hints from config routes and backends (h2c, HTTP/1.1 opt-in)
        if: matrix.check == 'early-hints'
        run: ci/e2e/early_hints.sh target/debug/rws
      - name: MCP gateway from the config file (two upstreams, ACLs, limits, audit log)
        if: matrix.check == 'mcp-gateway'
        run: ci/e2e/mcp_gateway.sh target/debug/rws
      - name: Admin API on its own listener
        if: matrix.check == 'admin'
        run: ci/e2e/admin.sh target/debug/rws
      - name: systemd notify (READY/RELOADING/WATCHDOG/STOPPING) and socket activation
        if: matrix.check == 'systemd'
        run: ci/e2e/systemd.sh target/debug/rws
      - name: Certificate revocation lists for mTLS (openssl CA, curl clients)
        if: matrix.check == 'mtls-crl'
        run: ci/e2e/mtls_crl.sh target/debug/rws
      - name: Local HTTPS (tls = "internal", rws trust into the runner's trust store)
        if: matrix.check == 'tls-internal'
        run: ci/e2e/tls_internal.sh target/debug/rws
      - name: FastCGI against php-fpm (front controller, PATH_INFO, uploads, Unix socket)
        if: matrix.check == 'fastcgi'
        run: |
          sudo apt-get update -qq && sudo apt-get install -y -qq php-fpm
          ci/e2e/fastcgi.sh target/debug/rws "$(ls /usr/sbin/php-fpm* | head -1)"
      - name: OIDC sign-in against Keycloak and forward-auth (config file)
        if: matrix.check == 'oidc'
        run: ci/e2e/oidc.sh target/debug/rws
      - name: WebSocket upgrades through config proxy routes (plain and TLS)
        if: matrix.check == 'ws-upgrade'
        run: ci/e2e/ws_upgrade.sh target/debug/rws
      - name: Runtime state shared across replicas through Redis
        if: matrix.check == 'cluster'
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q redis-server
          ci/e2e/cluster.sh target/debug/rws "$(command -v redis-server)"
      - name: Web application firewall through the binary
        if: matrix.check == 'waf'
        run: ci/e2e/waf.sh target/debug/rws
      - name: Fleet config pull (HTTP source, S3 via SeaweedFS)
        if: matrix.check == 'fleet'
        run: ci/e2e/fleet.sh target/debug/rws
      - name: WebAssembly plugins (unit tests with the feature; the example plugin through the binary)
        if: matrix.check == 'wasm-plugins'
        run: |
          rustup target add wasm32-unknown-unknown
          cargo test --lib --features wasm-plugins -- wasm_plugin stack_depth
          cargo build --bin rws --features "acme auth wasm-plugins"
          cargo build --release --target wasm32-unknown-unknown --manifest-path examples/wasm_plugin/Cargo.toml
          ci/e2e/wasm_plugin.sh target/debug/rws examples/wasm_plugin/target/wasm32-unknown-unknown/release/rws_wasm_plugin_example.wasm
      - name: rws as a wasi:http component (rws-wasm-shim under wasmtime serve)
        if: matrix.check == 'wasm-shim'
        run: |
          rustup target add wasm32-wasip2
          cargo build --release --target wasm32-wasip2 -p rws-wasm-shim
          curl -sSfL https://github.com/bytecodealliance/wasmtime/releases/download/v49.0.4/wasmtime-v49.0.4-x86_64-linux.tar.xz | tar xJ -C "$RUNNER_TEMP"
          ci/e2e/wasm_shim.sh target/wasm32-wasip2/release/rws_wasm_shim.wasm "$RUNNER_TEMP/wasmtime-v49.0.4-x86_64-linux/wasmtime"
      - uses: actions/setup-node@v4
        if: matrix.check == 'admin-ui'
        with:
          node-version: 24
      - uses: actions/setup-node@v4
        if: matrix.check == 'dictionary'
        with:
          node-version: 24
      - name: Dictionary compression for static files (Chromium, zstd CLI, libbrotlidec)
        if: matrix.check == 'dictionary'
        run: |
          (cd ci/e2e/dictionary && npm install --no-save --no-audit --no-fund playwright@1.64.0 && npx playwright install --with-deps chromium)
          ci/e2e/dictionary.sh target/debug/rws
      - name: Admin UI (embedded bundle up to date; headless Chromium against the binary)
        if: matrix.check == 'admin-ui'
        run: |
          admin-ui/build.sh --check
          (cd ci/e2e/admin_ui && npm install --no-save --no-audit --no-fund playwright@1.64.0 && npx playwright install --with-deps chromium)
          ci/e2e/admin_ui.sh target/debug/rws
      - name: LLM proxy from the config file (fallback, usage, budgets, Redis-shared budgets)
        if: matrix.check == 'llm-proxy'
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q redis-server
          ci/e2e/llm_proxy.sh target/debug/rws "$(command -v redis-server)"
      - name: Native histograms scraped by Prometheus 3 (protobuf /metrics)
        if: matrix.check == 'native-histograms'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download v3.15.0 --repo prometheus/prometheus --pattern 'prometheus-3.15.0.linux-amd64.tar.gz' --dir "$RUNNER_TEMP"
          tar xzf "$RUNNER_TEMP/prometheus-3.15.0.linux-amd64.tar.gz" -C "$RUNNER_TEMP"
          ci/e2e/native_histograms.sh target/debug/rws "$RUNNER_TEMP/prometheus-3.15.0.linux-amd64/prometheus"
      - name: OTLP metrics and traces into an OpenTelemetry Collector
        if: matrix.check == 'otlp'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download v0.162.0 --repo open-telemetry/opentelemetry-collector-releases --pattern 'otelcol-contrib_0.162.0_linux_amd64.tar.gz' --dir "$RUNNER_TEMP"
          tar xzf "$RUNNER_TEMP/otelcol-contrib_0.162.0_linux_amd64.tar.gz" -C "$RUNNER_TEMP"
          ci/e2e/otlp.sh target/debug/rws "$RUNNER_TEMP/otelcol-contrib"
      - name: gRPC against tonic (every call type, cleartext and mTLS upstreams; h2c with grpcurl)
        if: matrix.check == 'grpc'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q protobuf-compiler
          gh release download v1.9.4 --repo fullstorydev/grpcurl --pattern 'grpcurl_1.9.4_linux_x86_64.tar.gz' --dir "$RUNNER_TEMP"
          tar xzf "$RUNNER_TEMP/grpcurl_1.9.4_linux_x86_64.tar.gz" -C "$RUNNER_TEMP" grpcurl
          cargo build --locked --release --manifest-path ci/e2e/grpc/Cargo.toml --bins
          ci/e2e/grpc.sh target/debug/rws ci/e2e/grpc/target/release/server ci/e2e/grpc/target/release/client "$RUNNER_TEMP/grpcurl"
      - uses: actions/setup-node@v4
        if: matrix.check == 'grpc'
        with:
          node-version: 24
      - name: gRPC-web from a browser (headless Chromium, HTTP/1.1 and HTTP/2)
        if: matrix.check == 'grpc'
        run: |
          (cd ci/e2e/grpc_web && npm install --no-save --no-audit --no-fund playwright@1.64.0 && npx playwright install --with-deps chromium)
          ci/e2e/grpc_web.sh target/debug/rws ci/e2e/grpc/target/release/server
      - name: PROXY protocol v1/v2 behind HAProxy (plain and TLS)
        if: matrix.check == 'proxy-protocol'
        run: |
          sudo apt-get update -q && sudo apt-get install -y -q haproxy
          ci/e2e/proxy_protocol.sh target/debug/rws "$(command -v haproxy)"
      - uses: actions/setup-node@v4
        if: matrix.check == 'realtime'
        with:
          node-version: 24
      - name: WebSocket routes and streaming SSE (Node clients; plain/TLS, shared/per-core, SSE over HTTP/2)
        if: matrix.check == 'realtime'
        run: |
          cargo build --example realtime
          ci/e2e/realtime.sh target/debug/examples/realtime
      - name: Starters build and pass their own tests against this checkout
        if: matrix.check == 'starters'
        run: ci/e2e/starters.sh
      - name: Tower interop — tonic-health over h2c (grpcurl) and a tower-http layer
        if: matrix.check == 'tower'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          cargo build --example tower --features tower
          gh release download v1.9.4 --repo fullstorydev/grpcurl --pattern 'grpcurl_1.9.4_linux_x86_64.tar.gz' --dir "$RUNNER_TEMP"
          tar xzf "$RUNNER_TEMP/grpcurl_1.9.4_linux_x86_64.tar.gz" -C "$RUNNER_TEMP" grpcurl
          ci/e2e/tower.sh target/debug/examples/tower "$RUNNER_TEMP/grpcurl"
      - name: OpenAPI 3.1 document validated by openapi-spec-validator
        if: matrix.check == 'openapi'
        run: |
          cargo build --example mcp_inventory --features "macros serde sso openapi"
          python3 -m venv "$RUNNER_TEMP/oasv"
          "$RUNNER_TEMP/oasv/bin/pip" install --quiet openapi-spec-validator==0.9.0
          ci/e2e/openapi.sh target/debug/examples/mcp_inventory "$RUNNER_TEMP/oasv/bin/python"

  # Every parser that sees untrusted input — see fuzz/README.md. 30 s per
  # target on push; 10 minutes each in the weekly scheduled run.
  fuzz:
    name: fuzz
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@nightly
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: fuzz
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-fuzz
      - run: fuzz/run.sh ${{ github.event_name == 'schedule' && 600 || 30 }} 4
      - name: Keep crashing inputs
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-artifacts
          path: |
            fuzz/artifacts
            fuzz/logs

  audit:
    name: cargo audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: taiki-e/install-action@cargo-audit
      # Reads ignores (with justifications) from .cargo/audit.toml
      - run: cargo audit

  deny:
    name: cargo deny
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: taiki-e/install-action@cargo-deny
      # Advisories, licenses (permissive only), banned sources (crates.io
      # only) — deny.toml.
      - run: cargo deny check

  docs:
    name: site (rws8.tech)
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: site
    steps:
      - uses: actions/checkout@v4
        with:
          # Older docs versions are built from their release tags.
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: site/package-lock.json
      - run: npm ci
      - run: npm audit --audit-level=moderate
      - run: npm run build

  docs-mcp:
    name: docs MCP server (site/mcp)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: site/mcp
      - run: cargo test --locked --manifest-path site/mcp/Cargo.toml
      - run: cargo clippy --locked --manifest-path site/mcp/Cargo.toml --all-targets

  install-script:
    name: install.sh (${{ matrix.os }})
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      # The latest release, as `curl … | sh` runs it, and a pinned one.
      - run: sh install.sh --dir "$RUNNER_TEMP/latest" && "$RUNNER_TEMP/latest/rws" --version
      - run: cat install.sh | sh -s -- --version 18.8.3 --dir "$RUNNER_TEMP/pinned" && "$RUNNER_TEMP/pinned/rws" --version | grep -q 18.8.3
      - run: "! sh install.sh --version 0.0.1 --dir \"$RUNNER_TEMP/none\""
      # The copy the site serves, as users run it.
      - run: curl -fsSL https://rws8.tech/install.sh | sh -s -- --dir "$RUNNER_TEMP/site" && "$RUNNER_TEMP/site/rws" --version

  docs-check:
    name: docs links, settings and llms.txt
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Links between pages, links to repo files, every RWS_CONFIG_* on env-vars.md
      - run: python3 ci/check_docs.py
      # llms.txt / llms-full.txt are generated from docs/ — fails when not regenerated
      - run: python3 ci/gen_llms.py --check
      # The Cursor rule and Copilot instructions are generated from the agent skill
      - run: python3 ci/gen_agents.py --check
      - run: python3 ci/gen_bot_lists.py --check