Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 5 additions & 37 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
name: CI

on:
push:
branches:
- main
pull_request:
merge_group:
workflow_dispatch:

permissions:
Expand All @@ -15,34 +13,8 @@ concurrency:
cancel-in-progress: true

jobs:
security:
name: Security, FFI, and SBOM gates
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install pinned tools
uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
with:
version: 2026.8.10

- name: Run security gates
run: mise run security

- name: Upload CycloneDX evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: http-cyclonedx-sbom
path: build/security/http.cdx.json
if-no-files-found: error

quality:
name: Full quality check
name: Change verification
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
Expand Down Expand Up @@ -138,7 +110,6 @@ jobs:
fail-fast: false
matrix:
os:
- ubuntu-24.04
- macos-15
- windows-2025
steps:
Expand Down Expand Up @@ -177,20 +148,17 @@ jobs:
working-directory: packages/quic_core
run: gleam test

# The one status check branch protection requires,
# named the same in every P4suta repository.
# It passes only when every job it needs succeeded or was skipped.
required:
name: required
if: always()
needs: [security, quality, interop, otp, smoke]
needs: [quality, interop, otp, smoke]
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Fail unless every needed job succeeded or was skipped
- name: Require every selected job to succeed
env:
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
echo "${RESULTS}"
jq -e 'all(.[]; IN("success", "skipped"))' <<< "${RESULTS}"
jq -e 'length == 4 and all(.[]; . == "success")' <<< "${RESULTS}"
46 changes: 11 additions & 35 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: Nightly
name: Release verification

on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -53,6 +51,7 @@ jobs:
name: nightly-model-shard-${{ matrix.shard }}
path: build/model/
if-no-files-found: ignore
retention-days: 7

generated-campaigns:
name: ${{ matrix.mode }} million-case shard ${{ matrix.shard }}
Expand Down Expand Up @@ -98,6 +97,7 @@ jobs:
name: nightly-${{ matrix.mode }}-shard-${{ matrix.shard }}
path: build/campaign/${{ matrix.mode }}/
if-no-files-found: ignore
retention-days: 7

coverage-full:
name: Whole-repository coverage
Expand Down Expand Up @@ -128,6 +128,7 @@ jobs:
name: nightly-coverage-full
path: build/coverage/
if-no-files-found: ignore
retention-days: 7

interop-matrix:
name: Interop ${{ matrix.peer }} on OTP ${{ matrix.otp }}
Expand Down Expand Up @@ -172,6 +173,7 @@ jobs:
name: nightly-interop-${{ matrix.peer }}-otp-${{ matrix.otp }}
path: build/interop/
if-no-files-found: ignore
retention-days: 7

credential-matrix:
name: Credentials / ${{ matrix.platform }} / OTP ${{ matrix.otp }}
Expand Down Expand Up @@ -227,6 +229,7 @@ jobs:
name: nightly-credential-${{ matrix.platform }}-otp-${{ matrix.otp }}
path: build/credential-matrix/local/
if-no-files-found: ignore
retention-days: 7

credential-aggregate:
name: Aggregate credential attestations
Expand Down Expand Up @@ -263,6 +266,7 @@ jobs:
name: nightly-credential-aggregate
path: build/credential-matrix/report.json
if-no-files-found: ignore
retention-days: 7

faults-netem:
name: Network fault injection
Expand Down Expand Up @@ -299,38 +303,7 @@ jobs:
name: nightly-faults-netem
path: build/fault/
if-no-files-found: ignore

soak:
name: Sustained soak (informational)
runs-on: ubuntu-24.04
timeout-minutes: 180
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install pinned tools
uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
with:
version: 2026.8.10

- name: Run soak workload
id: soak
continue-on-error: true
run: mise run soak

- name: Annotate failure
if: steps.soak.outcome == 'failure'
run: echo "::error::nightly gate soak failed (informational, hosted timing is not evidence)"

- name: Upload soak evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-soak
path: packages/http3/benchmarks/results/
if-no-files-found: ignore
retention-days: 7

qlog-cddl:
name: qlog schema validation
Expand Down Expand Up @@ -361,6 +334,7 @@ jobs:
name: nightly-qlog-cddl
path: build/qlog/
if-no-files-found: ignore
retention-days: 7

errata-drift:
name: Requirement and errata drift
Expand Down Expand Up @@ -391,6 +365,7 @@ jobs:
name: nightly-errata-drift
path: build/requirements/
if-no-files-found: ignore
retention-days: 7

release-sim:
name: Three-package release simulation on OTP ${{ matrix.otp }}
Expand Down Expand Up @@ -429,3 +404,4 @@ jobs:
name: nightly-release-sim-otp-${{ matrix.otp }}
path: build/release-sim/
if-no-files-found: ignore
retention-days: 7
17 changes: 13 additions & 4 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,13 @@ run = [
"gleam run -m glinter -- --stats",
"cd packages/http3 && gleam run -m glinter -- --stats",
"cd packages/quic_core && gleam run -m glinter -- --stats",
"mise run lint-repository",
]

[tasks.lint-repository]
description = "Lint prose, configuration, workflows, shell scripts, spelling, licences, and secrets"
shell = "bash -c"
run = [
"markdownlint-cli2 \"**/*.md\" \"#build/**\" \"#deps/**\" \"#packages/http3/build/**\" \"#packages/http3/deps/**\" \"#packages/quic_core/build/**\" \"#packages/quic_core/deps/**\"",
"taplo fmt --check",
"taplo lint",
Expand Down Expand Up @@ -508,7 +515,8 @@ run = [
description = "Run every automated local gate and assemble an unpublished source bundle"
run = [
"mise run check",
"mise run security",
"mise run stability",
"mise run sbom",
"mise run coverage",
"mise run coverage-full",
"mise run property",
Expand All @@ -535,20 +543,21 @@ run = [
]

[tasks.check]
description = "Run every required local verification"
description = "Run deterministic change verification without release campaigns"
run = [
"mise run status-audit",
"mise run package-layout",
"mise run http-check",
"mise run http3-check",
"mise run core-check",
"mise run stability",
"mise run fresh-build",
"mise run fresh-build-audit",
"mise run coverage-coordinate-audit",
"mise run core-package",
"mise run mix-package",
"mise run ffi-audit",
"mise run api",
"mise run lint",
"mise run lint-repository",
"mise run semgrep",
"mise run dependency-audit",
]
47 changes: 22 additions & 25 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,20 @@ live failure to a focused Red case without weakening the complete-suite gate.

## Qualification campaigns

`mise run property`, `mise run fuzz`, and `mise run model` are deterministic
PR-scale gates. The generated corpus entrypoints also accept explicit seeds;
the nightly workflow divides one million cases or transitions across eight
independent shards. A shard report records its seed, size, source digest, and
result digest so repeating one failing shard does not require replaying the
whole campaign.
`mise run check` runs change verification on each pull request and merge group.
It checks all three packages, documentation, packaging, API boundaries, static rules, secrets, and dependency closures.
The platform jobs keep macOS, Windows, and OTP 28 and 29 coverage.

`mise run source-candidate` adds fixed-count stability repetitions, SBOM generation, and the full release qualification inventory.
It keeps property, fuzz, model, coverage, interoperability, credentials, fault, performance, and soak checks at the release boundary.
The release verification workflow runs only by manual dispatch.
It divides one million cases or transitions across eight independent shards and keeps uploaded evidence for seven days.
The informational hosted soak job is removed because its timing did not provide qualification evidence.
The source-candidate gate keeps the soak check.

`mise run property`, `mise run fuzz`, and `mise run model` run bounded deterministic campaigns.
The generated corpus entrypoints also accept explicit seeds.
A shard report records its seed, size, source digest, and result digest so repeating one failing shard does not require replaying the whole campaign.

Before either campaign runs, the harness tests its own failure locator. On a
corpus failure it reruns deterministic prefixes with binary search, records the
Expand All @@ -155,25 +163,14 @@ under each production `src` tree. Harness self-tests reject a missing or
non-canonical production path, so changing implementation or FFI code always
invalidates older campaign evidence.

The PR property and fuzz gates each run 10,000 generated cases for three
independently reported families: HTTP/3 wire and QPACK, QUIC core wire and TLS,
and the unified HTTP MASQUE receive boundary. The MASQUE property family checks
every receive transition against an independent counter model. Every generated
property case also validates an HTTP/1 proxy request and executes a supervised,
default-deny DNS/socket setup through idempotent cleanup. Successful cases feed
both a spoofed source and the exact resolved address-and-port through the
target-socket transition, bind that socket to one request-stream resource, and
select socket-unusable, request-stream-ended, or application-close termination
from the reproducible seed. They then verify first-reason stability, duplicate
notification convergence, and that packets arriving afterwards are discarded
by the inactive lifetime check before source or payload inspection. Its fuzz
family
retains malformed variable-length integers, non-byte-aligned input, unknown
contexts, and oversized Context ID zero payloads, and injects every retained or
generated byte string as a resolver answer. Only exact IPv4/IPv6 widths may
reach the socket callback. Drop, abort, setup, and cleanup evidence contains
saturating counters and typed reasons but never retains input payloads, target
names, socket values, or callback exception terms.
The default property and fuzz campaigns each run 10,000 generated cases for three independently reported families: HTTP/3 wire and QPACK, QUIC core wire and TLS, and the unified HTTP MASQUE receive boundary.
The MASQUE property family checks every receive transition against an independent counter model.
Every generated property case also validates an HTTP/1 proxy request and executes a supervised, default-deny DNS/socket setup through idempotent cleanup.
Successful cases feed both a spoofed source and the exact resolved address-and-port through the target-socket transition, bind that socket to one request-stream resource, and select socket-unusable, request-stream-ended, or application-close termination from the reproducible seed.
They then verify first-reason stability, duplicate notification convergence, and that packets arriving afterwards are discarded by the inactive lifetime check before source or payload inspection.
Its fuzz family retains malformed variable-length integers, non-byte-aligned input, unknown contexts, and oversized Context ID zero payloads, and injects every retained or generated byte string as a resolver answer.
Only exact IPv4/IPv6 widths may reach the socket callback.
Drop, abort, setup, and cleanup evidence contains saturating counters and typed reasons but never retains input payloads, target names, socket values, or callback exception terms.

The focused MASQUE fault suite additionally forces resolver and socket
timeouts, worker heap exhaustion, callback panics, deliberately malformed
Expand Down
24 changes: 12 additions & 12 deletions packages/http3/test/server_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -686,12 +686,21 @@ pub fn server_enforces_request_body_limit_test() -> Nil {
let configuration = server.with_timeout(configuration, 3000) |> should.be_ok
let listener = server.start(configuration) |> should.be_ok
let port = server.port(listener) |> should.be_ok
let client_task =
start_bounded_client_with_body(port, ca_certificate, <<"too large":utf8>>)
let connection =
client.connect(client_configuration(ca_certificate), "localhost", port)
|> should.be_ok
let stream =
streaming_request(port, "/limit")
|> request.set_method(http.Post)
|> client.open_stream(connection, _)
|> should.be_ok
client.send_chunk(stream, <<"too large":utf8>>) |> should.be_ok
assert client.next_event(stream)
== Error(client.Failure(failure.Closed(failure.Peer, Some(0x10c))))

let incoming = server.accept(listener) |> should.be_ok
assert server.read_body(incoming) == Error(server.RequestBodyTooLarge(4))
let _client_result = http3_test_support.await_task(client_task)
assert client.close(connection) == Ok(client.Closed)
assert server.stop(listener) == Ok(server.Stopped)
}

Expand Down Expand Up @@ -989,15 +998,6 @@ fn start_bounded_client(
start_bounded_client_with_path_and_body(port, ca_certificate, path, <<>>)
}

// nolint: label_possible -- positional test-helper arguments remain concise.
fn start_bounded_client_with_body(
port: Int,
ca_certificate: BitArray,
body: BitArray,
) -> http3_test_support.Task(Result(response.Response(BitArray), client.Error)) {
start_bounded_client_with_path_and_body(port, ca_certificate, "/limit", body)
}

// nolint: label_possible -- positional test-helper arguments remain concise.
fn start_bounded_client_with_path_and_body(
port: Int,
Expand Down
Loading