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
372 changes: 372 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
# Build elfuse and run the HVF runtime tests.
#
# build-macos : compile + entitlement check on macOS Apple Silicon
# 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.
name: Build

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:
# 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

runtime-macos:
name: Runtime (${{ matrix.name }})
needs: build-macos
if: >
github.repository == 'sysprog21/elfuse' &&
(github.event_name == 'push' || github.event_name == 'pull_request' ||
github.event_name == 'workflow_dispatch')
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 set per leg. Without
# that, a TSAN-slowed guest overruns both, 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 leaves each script its own default: 60s in
# tests/driver.sh, 10s in tests/lib/test-runner.sh. The 30 on the ASAN
# and UBSAN legs is therefore a raise for the lanes and a cut for the
# driver.
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
Loading
Loading