diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bcd89a..5f43e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,8 @@ name: CI on: - push: - branches: - - main pull_request: + merge_group: workflow_dispatch: permissions: @@ -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: @@ -138,7 +110,6 @@ jobs: fail-fast: false matrix: os: - - ubuntu-24.04 - macos-15 - windows-2025 steps: @@ -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}" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1b0522b..0cfc45d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,8 +1,6 @@ -name: Nightly +name: Release verification on: - schedule: - - cron: "0 3 * * *" workflow_dispatch: permissions: @@ -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 }} @@ -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 @@ -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 }} @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 }} @@ -429,3 +404,4 @@ jobs: name: nightly-release-sim-otp-${{ matrix.otp }} path: build/release-sim/ if-no-files-found: ignore + retention-days: 7 diff --git a/.mise.toml b/.mise.toml index fec2e01..b858dc7 100644 --- a/.mise.toml +++ b/.mise.toml @@ -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", @@ -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", @@ -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", ] diff --git a/docs/TESTING.md b/docs/TESTING.md index fc7381a..c110a3d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 @@ -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 diff --git a/packages/http3/test/server_test.gleam b/packages/http3/test/server_test.gleam index 3dabbdf..7abc674 100644 --- a/packages/http3/test/server_test.gleam +++ b/packages/http3/test/server_test.gleam @@ -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) } @@ -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,