Report a pty hangup on every path that can observe one #887
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Build elfuse and run the lint + analysis suites. | |
| # | |
| # Jobs run in parallel where runner capacity allows: | |
| # lint : format/newline/security/cppcheck/dispatch on Linux | |
| # build-macos : compile + entitlement check on macOS Apple Silicon | |
| # tidy-macos : clang-tidy via `make lint` | |
| # verify : Frama-C WP proofs of the attacker-facing arithmetic via | |
| # `make verify`; gating, not advisory | |
| # scan-macos : LLVM scan-build via `make analyze` | |
| # infer-macos : Facebook Infer capture + analyze over the full build | |
| # runtime-macos : HVF runtime tests on self-hosted Apple Silicon, | |
| # including release, ASAN, UBSAN, and TSAN variants | |
| # | |
| # Runtime and sanitizer tests require Hypervisor.framework, which | |
| # GitHub-hosted macOS runners do not expose. Those tests run on self-hosted | |
| # Apple Silicon runners; the hosted job stops at build. | |
| # | |
| # Within the lint job, all sub-checks run even if an earlier one fails so | |
| # the report shows every problem at once instead of stopping at the first. | |
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| paths-ignore: | |
| - '**.md' | |
| - 'docs/**' | |
| - 'LICENSE' | |
| pull_request: | |
| branches: [main] | |
| paths-ignore: | |
| - '**.md' | |
| - 'docs/**' | |
| - 'LICENSE' | |
| workflow_dispatch: | |
| # Cancel in-progress runs for the same PR; keep main runs going. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| jobs: | |
| # Lint: formatting + static analysis on a fast Linux runner. | |
| lint: | |
| name: Lint (Linux) | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| env: | |
| # Single source of truth for the apt package list. Used by both the | |
| # cache key (so unrelated workflow edits don't bust the cache) and | |
| # the install step. | |
| LINT_PKGS: clang-format-22 cppcheck shellcheck | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Cache apt packages | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/apt-cache | |
| key: apt-${{ runner.os }}-${{ env.LINT_PKGS }} | |
| - name: Add LLVM apt repo (clang-format-22) | |
| # Place the key in /etc/apt/keyrings and bind it via signed-by so | |
| # it grants trust only to the LLVM repository, not system-wide. | |
| run: | | |
| set -euo pipefail | |
| sudo install -d -m 0755 /etc/apt/keyrings | |
| wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key \ | |
| | sudo tee /etc/apt/keyrings/llvm.asc > /dev/null | |
| echo "deb [signed-by=/etc/apt/keyrings/llvm.asc] http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" \ | |
| | sudo tee /etc/apt/sources.list.d/llvm.list | |
| - name: Install tools | |
| run: | | |
| set -euo pipefail | |
| mkdir -p ~/apt-cache | |
| sudo apt-get update | |
| # shellcheck disable=SC2086 -- LINT_PKGS is a space-separated list. | |
| sudo apt-get install -y -o Dir::Cache::Archives="$HOME/apt-cache" \ | |
| $LINT_PKGS | |
| - name: Trailing newline | |
| if: ${{ !cancelled() }} | |
| run: .ci/check-newline.sh | |
| - name: clang-format | |
| if: ${{ !cancelled() }} | |
| run: .ci/check-format.sh | |
| - name: Banned APIs / secrets / unsafe pp directives | |
| if: ${{ !cancelled() }} | |
| run: .ci/check-security.sh | |
| - name: shellcheck | |
| # Scoped to .ci/ -- tests/ has pre-existing warnings that the | |
| # repository's own check-format target already surfaces. | |
| if: ${{ !cancelled() }} | |
| run: | | |
| set -euo pipefail | |
| mapfile -d '' files < <(git ls-files -z -- '.ci/*.sh') | |
| shellcheck --severity=warning "${files[@]}" | |
| - name: cppcheck | |
| if: ${{ !cancelled() }} | |
| run: .ci/check-cppcheck.sh | |
| - name: Syscall dispatch table consistency | |
| # The generator validates dispatch.tbl <-> syscall.c on every run; | |
| # writing to a throwaway path is enough to exercise validate_wrappers(). | |
| if: ${{ !cancelled() }} | |
| run: python3 scripts/gen-syscall-dispatch.py --output "$RUNNER_TEMP/dispatch.h" | |
| # Build verification on macOS Apple Silicon (no HVF runtime tests). | |
| # Hosted runners don't expose Hypervisor.framework, so this job stops at | |
| # `make elfuse` + entitlement check. | |
| build-macos: | |
| name: Build (macOS Apple Silicon) | |
| runs-on: macos-15 | |
| timeout-minutes: 15 | |
| env: | |
| GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy | |
| HOMEBREW_NO_INSTALL_CLEANUP: 1 | |
| HOMEBREW_NO_AUTO_UPDATE: 1 | |
| BREW_PKGS: binutils | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Cache Homebrew downloads | |
| # No restore-keys: a partial match would mask upstream regressions. | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/Library/Caches/Homebrew/downloads | |
| key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} | |
| - name: Confirm host is arm64 | |
| run: | | |
| set -euo pipefail | |
| uname -mrs | |
| test "$(uname -m)" = "arm64" | |
| - name: Install GNU objcopy | |
| # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. | |
| run: | | |
| set -euo pipefail | |
| brew install --quiet $BREW_PKGS | |
| "$GNU_OBJCOPY" --version | head -1 | |
| - name: Build elfuse | |
| run: | | |
| set -euo pipefail | |
| clang --version | head -1 | |
| make elfuse | |
| - name: Verify HVF entitlement is embedded | |
| run: | | |
| set -euo pipefail | |
| codesign -d --entitlements - build/elfuse 2>&1 \ | |
| | grep -q 'com\.apple\.security\.hypervisor' | |
| - name: Upload elfuse binary | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: elfuse-${{ runner.os }}-${{ runner.arch }} | |
| path: build/elfuse | |
| retention-days: 7 | |
| if-no-files-found: warn | |
| # clang-tidy via `make lint`. Runs in parallel with build/scan jobs. | |
| # Advisory: .clang-tidy sets WarningsAsErrors='', so findings are logged | |
| # for review but do not gate the job. | |
| tidy-macos: | |
| name: clang-tidy (macOS Apple Silicon) | |
| runs-on: macos-15 | |
| timeout-minutes: 20 | |
| env: | |
| GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy | |
| HOMEBREW_NO_INSTALL_CLEANUP: 1 | |
| HOMEBREW_NO_AUTO_UPDATE: 1 | |
| # binutils is needed because make lint depends on the shim_blob.h | |
| # generated by the assembly + objcopy pipeline. | |
| BREW_PKGS: binutils llvm | |
| CLANG_TIDY: /opt/homebrew/opt/llvm/bin/clang-tidy | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Cache Homebrew downloads | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/Library/Caches/Homebrew/downloads | |
| key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} | |
| - name: Install Homebrew packages | |
| # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. | |
| run: | | |
| set -euo pipefail | |
| brew install --quiet $BREW_PKGS | |
| "$CLANG_TIDY" --version | head -1 | |
| - name: Generate build/dispatch.h, shim_blob.h, version.h | |
| # `make lint` depends on these generated headers; building the | |
| # full elfuse binary is unnecessary, so just satisfy the deps. | |
| run: make build/shim_blob.h build/version.h build/dispatch.h | |
| - name: clang-tidy (make lint) | |
| run: make lint | |
| # Frama-C WP proofs of the attacker-facing arithmetic via `make verify`. | |
| # | |
| # GATING, unlike tidy-macos and scan-macos: the inputs these proofs cover come | |
| # from untrusted binaries and from the guest itself, so an unproved | |
| # obligation fails the job instead of being logged for review. Without this | |
| # job the proofs are only enforced when a human runs them, and they rot the | |
| # first time someone edits elf.c or gdbstub-rsp.c. | |
| verify: | |
| name: Frama-C WP proofs (make verify) | |
| runs-on: macos-15 | |
| timeout-minutes: 60 | |
| env: | |
| HOMEBREW_NO_INSTALL_CLEANUP: 1 | |
| HOMEBREW_NO_AUTO_UPDATE: 1 | |
| # graphviz/llvm/zlib are frama-c's system dependencies; conf-graphviz | |
| # fails without dot(1). The exact Python formula opam wants moves between | |
| # releases, so it is not listed here: OPAMCONFIRMLEVEL below lets opam | |
| # install whatever depexts it still needs rather than having this list | |
| # guess. Guessing python@3.11 when opam wanted python@3.9 is what made the | |
| # previous attempt abort. | |
| BREW_PKGS: opam gmp pkg-config graphviz llvm@17 zlib | |
| # Without this, opam's "some required external dependencies are missing" | |
| # prompt has no TTY to answer it, silently takes option 4 (abort), and the | |
| # step exits 10. | |
| OPAMCONFIRMLEVEL: unsafe-yes | |
| # The gate fails on any single [Timeout], and a shared runner is slower | |
| # than a dev machine (the three proofs take 3-9s each locally). The job | |
| # already has a 60-minute budget, so headroom here costs nothing and | |
| # removes a flake class that would read as a proof regression. | |
| FRAMAC_TIMEOUT: 120 | |
| # Pinned so the gating proofs run against a known toolchain. The opam | |
| # cache key below is built from these three, so bumping a version here is | |
| # all that is needed to install afresh rather than reuse a stale switch. | |
| FRAMAC_VERSION: "31.0" | |
| ALT_ERGO_VERSION: 2.6.3 | |
| Z3_VERSION: 4.16.0 | |
| OPAMROOT: ${{ github.workspace }}/.opam | |
| OPAM_SWITCH: frama-c-elfuse | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Cache Homebrew downloads | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/Library/Caches/Homebrew/downloads | |
| key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} | |
| - name: Install Homebrew packages | |
| # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. | |
| run: | | |
| set -euo pipefail | |
| brew install --quiet $BREW_PKGS | |
| # Building Frama-C and the provers from source takes tens of minutes, so | |
| # the whole opam root is cached. Bump the key suffix to force a rebuild. | |
| - name: Cache opam switch | |
| id: opam-cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ env.OPAMROOT }} | |
| # Keyed on the pinned versions, so changing any of them installs | |
| # afresh instead of silently reusing a stale toolchain. | |
| key: opam-${{ runner.os }}-${{ runner.arch }}-frama-c${{ env.FRAMAC_VERSION }}-ae${{ env.ALT_ERGO_VERSION }}-z3${{ env.Z3_VERSION }} | |
| - name: Install Frama-C, Alt-Ergo, Z3 | |
| if: steps.opam-cache.outputs.cache-hit != 'true' | |
| run: | | |
| set -euo pipefail | |
| opam init -y --bare --disable-sandboxing | |
| opam switch create "$OPAM_SWITCH" 4.14.1 | |
| eval "$(opam env --switch="$OPAM_SWITCH")" | |
| # No --assume-depexts: the system packages are installed above, and | |
| # asserting they exist when they do not is what made conf-graphviz | |
| # fail with "dot: command not found". | |
| opam install -y \ | |
| frama-c.$FRAMAC_VERSION \ | |
| alt-ergo.$ALT_ERGO_VERSION \ | |
| z3.$Z3_VERSION | |
| - name: Prove the parsers and translation (make verify) | |
| # why3 config detect runs here rather than in the install step: it | |
| # writes ~/.why3.conf, which lives outside OPAMROOT and so is absent on | |
| # a cache hit. Skipping it makes WP abort with "Prover not found in | |
| # why3.conf" instead of reporting unproved obligations, which the gate | |
| # would then report as "Frama-C emitted no result". | |
| run: | | |
| set -euo pipefail | |
| eval "$(opam env --switch="$OPAM_SWITCH")" | |
| why3 config detect | |
| frama-c -version | |
| make verify | |
| - name: Upload prover log | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: verify-logs | |
| path: build/verify-*.log | |
| if-no-files-found: warn | |
| # LLVM scan-build via `make analyze`. Runs in parallel with build/tidy. | |
| # Advisory: scan-build's Make target does not pass --status-bugs, so | |
| # findings appear in logs and in the uploaded HTML report but do not | |
| # gate the job. | |
| scan-macos: | |
| name: scan-build (macOS Apple Silicon) | |
| runs-on: macos-15 | |
| timeout-minutes: 25 | |
| env: | |
| GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy | |
| HOMEBREW_NO_INSTALL_CLEANUP: 1 | |
| HOMEBREW_NO_AUTO_UPDATE: 1 | |
| BREW_PKGS: binutils llvm | |
| LLVM_BIN: /opt/homebrew/opt/llvm/bin | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Cache Homebrew downloads | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/Library/Caches/Homebrew/downloads | |
| key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} | |
| - name: Install Homebrew packages | |
| # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. | |
| # scan-build has no --version; piping --help into `head -1` makes | |
| # perl take SIGPIPE on the closed stdout and exit non-zero, which | |
| # under pipefail fails the step. Just confirm the binary exists. | |
| run: | | |
| set -euo pipefail | |
| brew install --quiet $BREW_PKGS | |
| test -x "$LLVM_BIN/scan-build" | |
| "$LLVM_BIN/clang" --version | head -1 | |
| - name: scan-build (make analyze) | |
| run: | | |
| set -euo pipefail | |
| export PATH="$LLVM_BIN:$PATH" | |
| mkdir -p build/scan-build | |
| scan-build -o build/scan-build --use-cc="$(command -v clang)" \ | |
| make -B elfuse | |
| - name: Upload scan-build report | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: scan-build-${{ runner.os }}-${{ runner.arch }} | |
| path: build/scan-build | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| # Facebook Infer over the full elfuse build. Must run on macOS Apple | |
| # Silicon because the build needs Hypervisor.framework and -arch arm64; | |
| # Infer captures the real clang invocations, so it sees every TU. | |
| # | |
| # Gating (unlike tidy-macos/scan-macos): a separate step turns Infer's | |
| # report.json into inline ::error:: annotations plus a job summary, then | |
| # fails the job. Findings surface on the PR instead of a silent exit code, | |
| # so bugs are pruned at PR time instead of merged. | |
| infer-macos: | |
| name: Infer (macOS Apple Silicon) | |
| runs-on: macos-15 | |
| timeout-minutes: 25 | |
| env: | |
| GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy | |
| HOMEBREW_NO_INSTALL_CLEANUP: 1 | |
| HOMEBREW_NO_AUTO_UPDATE: 1 | |
| BREW_PKGS: binutils | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Cache Homebrew downloads | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/Library/Caches/Homebrew/downloads | |
| key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} | |
| - name: Install GNU objcopy | |
| # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. | |
| run: | | |
| set -euo pipefail | |
| brew install --quiet $BREW_PKGS | |
| "$GNU_OBJCOPY" --version | head -1 | |
| - name: Setup Infer | |
| # infer_version pins the Infer binary; the action itself tracks the v1 | |
| # tag. | |
| uses: srz-zumix/setup-infer@v1 | |
| with: | |
| infer_version: v1.3.0 | |
| # .inferconfig disables PULSE_UNINITIALIZED_VALUE repo-wide. Pulse cannot | |
| # prove guest_copy's chunked "while (copied < len)" loop fills its | |
| # destination, so every guest_read_small caller looks uninitialized; the | |
| # findings were audited and every caller checks the return value. The rest | |
| # of the Infer gate is untouched: null dereference, use-after-free, leaks, | |
| # dead stores and stack-address escape all still fail the job. | |
| # | |
| # The cost is real and repo-wide: a genuinely uninitialized read added | |
| # after this point is not caught here. Scoping it narrower was tried and | |
| # is worse -- the findings span thirteen files including syscall.c and | |
| # proc.c, so a path block list suppresses the same class over most of the | |
| # syscall surface while being harder to read, and censor-report does not | |
| # take effect through `infer run` in v1.3.0. `make infer-uninit` re-runs | |
| # the analysis with the checker back on and prints the count, so whether | |
| # an Infer upgrade has made this unnecessary is one command away. | |
| - name: Infer capture + analyze (make -B elfuse) | |
| # -B forces a clean rebuild so Infer captures every translation unit. | |
| # Non-C build steps (shim.S assembly, objcopy) pass through untouched. | |
| # No --fail-on-issue here: `infer run` must exit 0 so the reporting | |
| # step below runs and surfaces findings before the job fails. | |
| # --keep-going tolerates a frontend failure on an odd TU, but that can | |
| # also mask a total capture miss (wrapper never intercepts clang, 0 | |
| # files analyzed). The count guard fails the job on that silent no-op. | |
| run: | | |
| set -euo pipefail | |
| infer run --keep-going -- make -B elfuse 2>&1 | tee infer-run.log | |
| n=$(grep -oE 'Found [0-9]+ source file' infer-run.log \ | |
| | grep -oE '[0-9]+' | tail -1 || true) | |
| echo "Infer captured ${n:-0} source files" | |
| test "${n:-0}" -gt 0 | |
| - name: Report Infer findings | |
| # Emit GitHub annotations + a job summary from report.json, then exit | |
| # non-zero if any finding exists. Runs even when a prior step failed so | |
| # a partial report is still surfaced. | |
| if: ${{ !cancelled() }} | |
| run: python3 scripts/infer-annotate.py infer-out/report.json | |
| - name: Upload Infer report | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: infer-${{ runner.os }}-${{ runner.arch }} | |
| path: infer-out/report.txt | |
| retention-days: 7 | |
| if-no-files-found: warn | |
| runtime-macos: | |
| name: Runtime (${{ matrix.name }}) | |
| needs: build-macos | |
| if: > | |
| github.repository == 'sysprog21/elfuse' && | |
| (github.event_name == 'push' || github.event_name == 'pull_request') | |
| runs-on: [self-hosted, macOS, arm64] | |
| # Sanitizer builds run several times slower than the release build, so the | |
| # job budget and the per-test TEST_TIMEOUT are widened per leg. Without | |
| # that, a TSAN-slowed guest overruns the 10s default TEST_TIMEOUT and the | |
| # 20-minute job budget, surfacing as TIMEOUT reds indistinguishable from a | |
| # real hang. | |
| timeout-minutes: ${{ matrix.job_timeout }} | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 4 | |
| matrix: | |
| include: | |
| - name: Release | |
| sanitizer: release | |
| extra_cflags: '' | |
| asan_options: '' | |
| ubsan_options: '' | |
| tsan_options: '' | |
| test_timeout: '' | |
| job_timeout: 20 | |
| run_matrix: true | |
| check_target: check | |
| brew_pkgs: binutils qemu | |
| - name: ASAN | |
| sanitizer: asan | |
| extra_cflags: -O1 -g -fsanitize=address -fno-omit-frame-pointer | |
| asan_options: abort_on_error=1:detect_leaks=0 | |
| ubsan_options: '' | |
| tsan_options: '' | |
| test_timeout: '30' | |
| job_timeout: 30 | |
| run_matrix: false | |
| check_target: check-sanitizer | |
| brew_pkgs: binutils | |
| - name: UBSAN | |
| sanitizer: ubsan | |
| extra_cflags: -O1 -g -fsanitize=undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer | |
| asan_options: '' | |
| ubsan_options: halt_on_error=1:print_stacktrace=1 | |
| tsan_options: '' | |
| test_timeout: '30' | |
| job_timeout: 30 | |
| run_matrix: false | |
| check_target: check-sanitizer | |
| brew_pkgs: binutils | |
| - name: TSAN | |
| sanitizer: tsan | |
| extra_cflags: -O1 -g -fsanitize=thread -fno-omit-frame-pointer | |
| asan_options: '' | |
| ubsan_options: '' | |
| tsan_options: halt_on_error=1 | |
| test_timeout: '60' | |
| job_timeout: 45 | |
| run_matrix: false | |
| check_target: check-sanitizer | |
| brew_pkgs: binutils | |
| # contents: read for the checkout; pull-requests: read so the guard can | |
| # query the PR's current HEAD. (actions: write would let the guard | |
| # cancel the run instead of failing it, but repo policy caps the token | |
| # at actions: read, so the guard fails fast with a clear reason instead.) | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: runtime-macos-${{ matrix.sanitizer }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| env: | |
| LINUX_TOOLCHAIN: /opt/toolchain/aarch64-linux-gnu | |
| GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy | |
| EXTRA_CFLAGS: ${{ matrix.extra_cflags }} | |
| ASAN_OPTIONS: ${{ matrix.asan_options }} | |
| UBSAN_OPTIONS: ${{ matrix.ubsan_options }} | |
| TSAN_OPTIONS: ${{ matrix.tsan_options }} | |
| # Empty on the release leg falls back to test-runner.sh's 10s default. | |
| TEST_TIMEOUT: ${{ matrix.test_timeout }} | |
| HOMEBREW_NO_INSTALL_CLEANUP: 1 | |
| HOMEBREW_NO_AUTO_UPDATE: 1 | |
| # qemu is only needed by test-matrix (release leg); sanitizer legs run the | |
| # fixture-free check-sanitizer subset and skip it. | |
| BREW_PKGS: ${{ matrix.brew_pkgs }} | |
| # Parallelize compilation; the guest-test cross-compile and elfuse build | |
| # dominate the non-test wall time. | |
| MAKEFLAGS: -j8 | |
| steps: | |
| # Fail fast if this run targets a commit that is no longer the PR's | |
| # HEAD. cancel-in-progress covers "commit 2 pushed while commit 1 is | |
| # still running", but NOT a manual "Re-run jobs" on an old run: a | |
| # re-run replays the original event payload (a frozen head.sha) | |
| # against this single self-hosted runner, which would otherwise burn | |
| # the full job timeout re-testing stale code. Compare the frozen | |
| # head.sha against the live PR HEAD; when they differ, exit 1 with a | |
| # clear "commit is no longer the latest" message. We fail (rather than | |
| # cancel) because repo policy caps the token at actions: read, so the | |
| # cancel API is unavailable. exit 1 also stops the job, so the later | |
| # steps are skipped automatically -- no per-step guard needed. The | |
| # lookup fails open: if HEAD can't be determined the job runs. | |
| - name: Fail fast if superseded by a newer PR commit | |
| if: github.event_name == 'pull_request' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| RUN_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -uo pipefail | |
| # curl and system python3 are always present on macOS; jq/gh are | |
| # not guaranteed on a self-hosted runner, so don't depend on them. | |
| latest=$(curl -fsSL \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "https://api.github.com/repos/$REPO/pulls/$PR_NUMBER" \ | |
| | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["sha"])') \ | |
| || latest="" | |
| echo "Run targets : $RUN_SHA" | |
| echo "PR HEAD now : ${latest:-<unknown>}" | |
| if [ -n "$latest" ] && [ "$latest" != "$RUN_SHA" ]; then | |
| echo "::error::This run targets $RUN_SHA, but PR #$PR_NUMBER HEAD is now $latest -- the commit is no longer the latest. Failing instead of re-testing stale code on the self-hosted runner; re-run CI on the current commit." | |
| exit 1 | |
| fi | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Restore cached test fixtures | |
| # Only the release leg needs fixtures: the sanitizer legs run the | |
| # fixture-free check-sanitizer subset. | |
| if: ${{ matrix.run_matrix }} | |
| # actions/checkout's default clean:true runs `git clean -ffdx`, which | |
| # wipes externals/test-fixtures (gitignored) on this self-hosted | |
| # runner even though its disk otherwise persists across runs. | |
| # fetch-fixtures.sh is already idempotent -- it skips re-downloading | |
| # Alpine packages when externals/test-fixtures/versions.lock still | |
| # matches -- so stash that tree outside the workspace and restore it | |
| # here as a real directory. The qemu lane in tests/test-matrix.sh | |
| # shares the workspace root with the guest over virtio-9p, and a | |
| # symlink pointing outside that root does not resolve inside the | |
| # guest, so this must be a real copy, not a symlink. | |
| run: | | |
| cache="$HOME/.cache/elfuse-ci/test-fixtures" | |
| if [ -d "$cache" ]; then | |
| mkdir -p externals | |
| rm -rf externals/test-fixtures | |
| cp -Rc "$cache" externals/test-fixtures | |
| echo "Restored test fixtures ($(du -sh externals/test-fixtures | cut -f1), lock: $(head -1 externals/test-fixtures/versions.lock 2>/dev/null || echo none))" | |
| else | |
| echo "No fixtures cache at $cache; tests fetch on demand" | |
| fi | |
| - name: Host info | |
| run: | | |
| sw_vers | |
| uname -a | |
| uname -m | |
| sysctl kern.hv_support || true | |
| test "$(uname -m)" = "arm64" | |
| - name: Cache Homebrew downloads | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/Library/Caches/Homebrew/downloads | |
| key: brew-runtime-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} | |
| - name: Install missing Homebrew packages | |
| run: | | |
| missing=() | |
| for pkg in $BREW_PKGS; do | |
| if ! brew list --formula "$pkg" >/dev/null 2>&1; then | |
| missing+=("$pkg") | |
| fi | |
| done | |
| if [ "${#missing[@]}" -gt 0 ]; then | |
| brew install --quiet "${missing[@]}" | |
| else | |
| echo "All Homebrew packages are already installed: $BREW_PKGS" | |
| fi | |
| - name: Tool versions | |
| run: | | |
| command -v make | |
| command -v "$GNU_OBJCOPY" | |
| make -V .MAKE.VERSION 2>/dev/null || true | |
| "$GNU_OBJCOPY" --version | head -1 | |
| qemu-aarch64 --version | head -1 || true | |
| python3 --version | |
| - name: Check Rosetta for Linux | |
| # Rosetta is exercised only by test-matrix (release leg); the | |
| # check-sanitizer subset has no x86_64-via-Rosetta tests. | |
| if: ${{ matrix.run_matrix }} | |
| run: | | |
| ROSETTA=/Library/Apple/usr/libexec/oah/RosettaLinux/rosetta | |
| if [ ! -x "$ROSETTA" ]; then | |
| echo "::error::Rosetta for Linux runtime was not found at $ROSETTA" | |
| echo | |
| echo "Install Rosetta on the self-hosted Mac runner first:" | |
| echo " sudo softwareupdate --install-rosetta --agree-to-license" | |
| echo | |
| echo "Current /Library/Apple/usr/libexec/oah contents:" | |
| ls -R /Library/Apple/usr/libexec/oah || true | |
| exit 1 | |
| fi | |
| ls -l "$ROSETTA" | |
| - name: Build elfuse | |
| # make does not track EXTRA_CFLAGS changes, so an object built for one | |
| # sanitizer must not be reused for another. Checkout already wipes | |
| # build/ (git clean -ffdx), but clean explicitly so the leg builds from | |
| # scratch even on a workspace that was not freshly cleaned. | |
| run: | | |
| make clean | |
| make EXTRA_CFLAGS="$EXTRA_CFLAGS" elfuse | |
| - name: Verify HVF entitlement is embedded | |
| run: | | |
| codesign -d --entitlements - build/elfuse 2>&1 \ | |
| | grep -q 'com\.apple\.security\.hypervisor' | |
| - name: test-hello | |
| run: | | |
| make EXTRA_CFLAGS="$EXTRA_CFLAGS" test-hello | |
| - name: test-multi-vcpu | |
| run: | | |
| make EXTRA_CFLAGS="$EXTRA_CFLAGS" test-multi-vcpu | |
| - name: make check | |
| # Release runs the full check suite; sanitizer legs run check-sanitizer, | |
| # a representative internal-implementation subset (the release lane plus | |
| # test-matrix already cover Linux syscall compatibility). | |
| run: | | |
| make EXTRA_CFLAGS="$EXTRA_CFLAGS" ${{ matrix.check_target }} | |
| - name: Test matrix | |
| if: ${{ matrix.run_matrix }} | |
| run: | | |
| bash tests/test-matrix.sh all | |
| - name: Upload runtime binary | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: elfuse-runtime-${{ matrix.sanitizer }}-${{ runner.os }}-${{ runner.arch }} | |
| path: build/elfuse | |
| retention-days: 7 | |
| if-no-files-found: warn | |
| - name: Save test fixtures cache | |
| # Persist externals/test-fixtures outside the workspace so the next | |
| # run's "Restore cached test fixtures" step can skip re-downloading | |
| # unchanged Alpine packages. Runs even if an earlier step failed, as | |
| # long as the job wasn't cancelled, so a fixture-unrelated test | |
| # failure doesn't cost the next run its cache. | |
| if: ${{ !cancelled() && matrix.sanitizer == 'release' }} | |
| run: | | |
| if [ -d externals/test-fixtures ]; then | |
| cache="$HOME/.cache/elfuse-ci/test-fixtures" | |
| mkdir -p "$(dirname "$cache")" | |
| rm -rf "$cache" | |
| cp -Rc externals/test-fixtures "$cache" | |
| echo "Saved test fixtures ($(du -sh "$cache" | cut -f1))" | |
| else | |
| echo "No externals/test-fixtures to save" | |
| fi |