|
6 | 6 | # tidy-macos : clang-tidy via `make lint` |
7 | 7 | # verify : Frama-C WP proofs of the attacker-facing arithmetic via |
8 | 8 | # `make verify`; gating, not advisory |
| 9 | +# verify-mutants: shows each proof rejects a known-broken source, one job |
| 10 | +# per proof target so the 40-mutation set runs as nine |
| 11 | +# parallel shards instead of one long serial job |
9 | 12 | # scan-macos : LLVM scan-build via `make analyze` |
10 | 13 | # infer-macos : Facebook Infer capture + analyze over the full build |
11 | 14 | # runtime-macos : HVF runtime tests on self-hosted Apple Silicon, |
@@ -114,6 +117,15 @@ jobs: |
114 | 117 | if: ${{ !cancelled() }} |
115 | 118 | run: python3 scripts/gen-syscall-dispatch.py --output "$RUNNER_TEMP/dispatch.h" |
116 | 119 |
|
| 120 | + - name: Mutation matrix consistency |
| 121 | + # verify-mutants (the Frama-C job below) hand-lists the same proof |
| 122 | + # targets mk/analysis.mk defines, sharded one job per target. A |
| 123 | + # target present in one but not the other silently drops that |
| 124 | + # target's mutation coverage from CI with no error, so catch the |
| 125 | + # drift here instead. |
| 126 | + if: ${{ !cancelled() }} |
| 127 | + run: python3 scripts/check-mutant-matrix-sync.py |
| 128 | + |
117 | 129 | # Build verification on macOS Apple Silicon (no HVF runtime tests). |
118 | 130 | # Hosted runners don't expose Hypervisor.framework, so this job stops at |
119 | 131 | # `make elfuse` + entitlement check. |
@@ -312,6 +324,159 @@ jobs: |
312 | 324 | path: build/verify-*.log |
313 | 325 | if-no-files-found: warn |
314 | 326 |
|
| 327 | + # Shows each Frama-C proof target rejects a known-broken source. Split from |
| 328 | + # the "verify" job (needs: verify, so a broken proof fails fast without |
| 329 | + # spending nine runners on mutating it) and sharded one job per proof |
| 330 | + # target: a caught mutation grinds against every unprovable goal until |
| 331 | + # FRAMAC_TIMEOUT, so the 40-mutation set run as one job is minutes where |
| 332 | + # "make verify" is seconds. Sharding trades that for nine runners in |
| 333 | + # parallel, each bounded by its own target's mutation count instead of the |
| 334 | + # whole set's. |
| 335 | + # |
| 336 | + # The matrix list is VERIFY_<T>_SRC's targets from mk/analysis.mk, hand-kept |
| 337 | + # in step: a target missing here silently drops its mutation coverage from |
| 338 | + # CI with no error, so add new proof targets to both places. |
| 339 | + verify-mutants: |
| 340 | + name: Mutation gate (${{ matrix.target }}) |
| 341 | + needs: verify |
| 342 | + runs-on: macos-15 |
| 343 | + timeout-minutes: 60 |
| 344 | + strategy: |
| 345 | + fail-fast: false |
| 346 | + matrix: |
| 347 | + target: |
| 348 | + - cmsg |
| 349 | + - elf |
| 350 | + - fuse |
| 351 | + - gva |
| 352 | + - netlink |
| 353 | + - rsp |
| 354 | + - sigframe |
| 355 | + - sockaddr |
| 356 | + - stack |
| 357 | + env: |
| 358 | + HOMEBREW_NO_INSTALL_CLEANUP: 1 |
| 359 | + HOMEBREW_NO_AUTO_UPDATE: 1 |
| 360 | + BREW_PKGS: opam gmp pkg-config graphviz llvm@17 zlib |
| 361 | + OPAMCONFIRMLEVEL: unsafe-yes |
| 362 | + # Deliberately far below the verify job's 120, and the single biggest |
| 363 | + # term in this job's runtime. A mutation is caught by leaving a goal |
| 364 | + # open, and an open goal is one both provers spend the whole timeout |
| 365 | + # failing to discharge, so every caught mutation costs two full |
| 366 | + # timeouts per goal. The verify job wants headroom because one |
| 367 | + # [Timeout] there is a false proof regression; here the pressure runs |
| 368 | + # the other way. |
| 369 | + # |
| 370 | + # Cutting it does not weaken the verdict, because check-mutants.py |
| 371 | + # proves an UNMUTATED copy of each source through this same path |
| 372 | + # first (check_baseline). A value too tight to prove real code fails |
| 373 | + # that control loudly instead of silently scoring mutations as caught. |
| 374 | + # The margin is wide: all nine targets together discharge 392 |
| 375 | + # obligations in 31s locally, the slowest single target in 9s. |
| 376 | + FRAMAC_TIMEOUT: 45 |
| 377 | + FRAMAC_VERSION: "31.0" |
| 378 | + ALT_ERGO_VERSION: 2.6.3 |
| 379 | + Z3_VERSION: 4.16.0 |
| 380 | + OPAMROOT: ${{ github.workspace }}/.opam |
| 381 | + OPAM_SWITCH: frama-c-elfuse |
| 382 | + steps: |
| 383 | + - name: Checkout |
| 384 | + uses: actions/checkout@v7 |
| 385 | + |
| 386 | + - name: Cache Homebrew downloads |
| 387 | + uses: actions/cache@v6 |
| 388 | + with: |
| 389 | + path: ~/Library/Caches/Homebrew/downloads |
| 390 | + key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} |
| 391 | + |
| 392 | + - name: Install Homebrew packages |
| 393 | + # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. |
| 394 | + run: | |
| 395 | + set -euo pipefail |
| 396 | + brew install --quiet $BREW_PKGS |
| 397 | +
|
| 398 | + # Same cache key as the verify job's opam switch, so this restores the |
| 399 | + # switch that job already built instead of rebuilding it nine times. |
| 400 | + - name: Cache opam switch |
| 401 | + id: opam-cache |
| 402 | + uses: actions/cache@v6 |
| 403 | + with: |
| 404 | + path: ${{ env.OPAMROOT }} |
| 405 | + key: opam-${{ runner.os }}-${{ runner.arch }}-frama-c${{ env.FRAMAC_VERSION }}-ae${{ env.ALT_ERGO_VERSION }}-z3${{ env.Z3_VERSION }} |
| 406 | + |
| 407 | + - name: Install Frama-C, Alt-Ergo, Z3 |
| 408 | + if: steps.opam-cache.outputs.cache-hit != 'true' |
| 409 | + run: | |
| 410 | + set -euo pipefail |
| 411 | + opam init -y --bare --disable-sandboxing |
| 412 | + opam switch create "$OPAM_SWITCH" 4.14.1 |
| 413 | + eval "$(opam env --switch="$OPAM_SWITCH")" |
| 414 | + opam install -y \ |
| 415 | + frama-c.$FRAMAC_VERSION \ |
| 416 | + alt-ergo.$ALT_ERGO_VERSION \ |
| 417 | + z3.$Z3_VERSION |
| 418 | +
|
| 419 | + - name: Prove the gate bites (make verify-mutants) |
| 420 | + # why3 config detect is required here too: it writes ~/.why3.conf, |
| 421 | + # which lives outside OPAMROOT and so is absent on a cache hit even |
| 422 | + # though the verify job already ran this once on its own runner. |
| 423 | + # |
| 424 | + # MUTANT_JOBS is set explicitly for the same reason as before |
| 425 | + # sharding: this runner has few enough cores that the script's |
| 426 | + # one-per-core default lands near serial. |
| 427 | + run: | |
| 428 | + set -euo pipefail |
| 429 | + eval "$(opam env --switch="$OPAM_SWITCH")" |
| 430 | + why3 config detect |
| 431 | + # On a pull request, re-verify only the targets whose source the |
| 432 | + # branch actually touches; a target it does not touch has the verdict |
| 433 | + # the base already established. A push to the base runs the full set, |
| 434 | + # so the guarantee is never weaker than the branch it merges into. |
| 435 | + # The checkout is shallow, so the base commit has to be fetched |
| 436 | + # before it can be diffed against. If that does not work the script |
| 437 | + # says so and runs the full set, which is the safe direction for an |
| 438 | + # optimization. |
| 439 | + base="${{ github.event.pull_request.base.sha }}" |
| 440 | + if [ -n "$base" ] && git fetch --no-tags --depth=1 origin "$base"; then |
| 441 | + make verify-mutants MUTANT_JOBS=4 MUTANT_TARGET=${{ matrix.target }} MUTANT_SINCE="$base" |
| 442 | + else |
| 443 | + make verify-mutants MUTANT_JOBS=4 MUTANT_TARGET=${{ matrix.target }} |
| 444 | + fi |
| 445 | +
|
| 446 | + - name: Upload prover log |
| 447 | + if: always() |
| 448 | + uses: actions/upload-artifact@v7 |
| 449 | + with: |
| 450 | + name: verify-mutants-logs-${{ matrix.target }} |
| 451 | + path: build/verify-mutants/*.log |
| 452 | + if-no-files-found: warn |
| 453 | + retention-days: 7 |
| 454 | + |
| 455 | + # One stable check name covering the whole mutation matrix, so branch |
| 456 | + # protection has something to require. The matrix leg names carry the target |
| 457 | + # in them ("Mutation gate (fuse)"), which means every added proof target |
| 458 | + # would otherwise need its own branch-protection entry, and a target added |
| 459 | + # without that entry would be unenforced from the day it landed. |
| 460 | + # |
| 461 | + # needs.<matrix job>.result is the aggregate over all legs, so this passes |
| 462 | + # only when every one of them did. if: always() is what makes it run at all |
| 463 | + # when a leg fails; without it this job would be skipped, and a skipped |
| 464 | + # required check does not block a merge. A skipped matrix (verify failed |
| 465 | + # upstream) reports "skipped" here too, which the equality test rejects. |
| 466 | + verify-mutants-gate: |
| 467 | + name: Mutation gate |
| 468 | + needs: verify-mutants |
| 469 | + if: always() |
| 470 | + runs-on: ubuntu-latest |
| 471 | + timeout-minutes: 5 |
| 472 | + steps: |
| 473 | + - name: Require every matrix leg to have passed |
| 474 | + run: | |
| 475 | + set -euo pipefail |
| 476 | + result='${{ needs.verify-mutants.result }}' |
| 477 | + echo "mutation matrix: $result" |
| 478 | + [ "$result" = success ] || exit 1 |
| 479 | +
|
315 | 480 | # LLVM scan-build via `make analyze`. Runs in parallel with build/tidy. |
316 | 481 | # Advisory: scan-build's Make target does not pass --status-bugs, so |
317 | 482 | # findings appear in logs and in the uploaded HTML report but do not |
|
0 commit comments