diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 741e135..3f2dd92 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,12 +21,14 @@ concurrency:
 
 jobs:
   build:
-    runs-on: ubuntu-22.04
     strategy:
       fail-fast: false
       matrix:
+        runner:
+          - macos-13 # x86
+          - macos-latest # arm64
+          - ubuntu-latest # x86
         rust:
-          - stable
           - 1.80.1
         program:
           - kprobe
@@ -48,6 +50,7 @@ jobs:
           - raw_tracepoint
           - tp_btf
           - tracepoint
+    runs-on: ${{ matrix.runner }}
 
     steps:
       - uses: actions/checkout@v4
@@ -67,6 +70,4 @@ jobs:
         with:
           tool: bpf-linker,cargo-generate
 
-      - run: sudo apt update
-      - run: sudo apt install expect
-      - run: ./test.sh ${{ github.workspace }} ${{ matrix.program }}
+      - run: ./test.py ${{ github.workspace }} ${{ matrix.program }}
diff --git a/README.md b/README.md
index cb40246..af1f095 100644
--- a/README.md
+++ b/README.md
@@ -21,11 +21,12 @@ experience; this compromise necessitates the use of `xtask` to actually build th
 
 Cross compilation should work on both Intel and Apple Silicon Macs.
 
-```bash
+```shell
 AYA_BUILD_EBPF=true \
 CC=${ARCH}-linux-musl-gcc \
-RUSTFLAGS="-C linker=${ARCH}-linux-musl-gcc" \
-  cargo build --package {{project-name}} --release --target=${ARCH}-unknown-linux-musl
+  cargo build --package {{project-name}} --release \
+  --target=${ARCH}-unknown-linux-musl \
+  --config=target.${ARCH}-unknown-linux-musl.linker=\"${ARCH}-linux-musl-gcc\"
 ```
 The cross-compiled program `target/${ARCH}-unknown-linux-musl/release/{{project-name}}` can be
 copied to a Linux server or VM and run there.
diff --git a/cargo-generate.toml b/cargo-generate.toml
index 6026555..efa3ccc 100644
--- a/cargo-generate.toml
+++ b/cargo-generate.toml
@@ -1,6 +1,6 @@
 [template]
 cargo_generate_version = ">=0.10.0"
-ignore = [".github", "test.sh"]
+ignore = [".github", "test.py"]
 
 [placeholders.program_type]
 type = "string"
diff --git a/test.py b/test.py
new file mode 100755
index 0000000..15f3dcd
--- /dev/null
+++ b/test.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+
+import argparse
+import os
+import platform
+import signal
+import subprocess
+import sys
+import tempfile
+from typing import TypedDict
+
+if platform.system() == "Linux":
+    import asyncio
+
+
+class SubprocessArgs(TypedDict, total=False):
+    cwd: str
+    env: dict[str, str]
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="Generate and build a Rust project using cargo."
+    )
+    parser.add_argument("template_dir", help="Template directory")
+    parser.add_argument("program_type", help="Program type")
+    args = parser.parse_args()
+
+    match args.program_type:
+        case "cgroup_sockopt":
+            additional_args = ["-d", "sockopt_target=getsockopt"]
+        case "classifier", "cgroup_skb":
+            additional_args = ["-d", "direction=Ingress"]
+        case "fentry", "fexit":
+            additional_args = ["-d", "fn_name=try_to_wake_up"]
+        case "kprobe", "kretprobe":
+            additional_args = ["-d", "kprobe=do_unlinkat"]
+        case "lsm":
+            additional_args = ["-d", "lsm_hook=file_open"]
+        case "raw_tracepoint":
+            additional_args = ["-d", "tracepoint_name=sys_enter"]
+        case "sk_msg":
+            additional_args = ["-d", "sock_map=SOCK_MAP"]
+        case "tp_btf":
+            additional_args = ["-d", "tracepoint_name=net_dev_queue"]
+        case "tracepoint":
+            additional_args = [
+                "-d",
+                "tracepoint_category=net",
+                "-d",
+                "tracepoint_name=net_dev_queue",
+            ]
+        case "uprobe", "uretprobe":
+            additional_args = [
+                "-d",
+                "uprobe_target=/proc/self/exe",
+                "-d",
+                "uprobe_fn_name=main",
+            ]
+        case _:
+            additional_args = []
+
+    CRATE_NAME = "aya-test-crate"
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        cmds: list[tuple[list[str], SubprocessArgs]] = [
+            (
+                [
+                    "cargo",
+                    "generate",
+                    "--path",
+                    args.template_dir,
+                    "-n",
+                    CRATE_NAME,
+                    "-d",
+                    f"program_type={args.program_type}",
+                ]
+                + additional_args,
+                {"cwd": tmp_dir},
+            ),
+        ]
+        project_dir = os.path.join(tmp_dir, CRATE_NAME)
+        match platform.system():
+            case "Linux":
+                cmds.extend(
+                    (cmd, {"cwd": project_dir})
+                    for cmd in (
+                        ["cargo", "+nightly", "fmt", "--all", "--", "--check"],
+                        ["cargo", "build", "--package", CRATE_NAME],
+                        ["cargo", "build", "--package", CRATE_NAME, "--release"],
+                        # We cannot run clippy over the whole workspace at once due to feature unification.
+                        # Since both ${CRATE_NAME} and ${CRATE_NAME}-ebpf depend on ${CRATE_NAME}-common and
+                        # ${CRATE_NAME} activates ${CRATE_NAME}-common's aya dependency, we end up trying to
+                        # compile the panic handler twice: once from the bpf program, and again from std via
+                        # aya.
+                        [
+                            "cargo",
+                            "--exclude",
+                            f"{CRATE_NAME}-ebpf",
+                            "--all-targets",
+                            "--workspace",
+                            "--",
+                            "--deny",
+                            "warnings",
+                        ],
+                        [
+                            "cargo",
+                            "--package",
+                            f"{CRATE_NAME}-ebpf",
+                            "--all-targets",
+                            "--",
+                            "--deny",
+                            "warnings",
+                        ],
+                    )
+                )
+            case "Darwin":
+                arch = platform.machine()
+                if arch == "arm64":
+                    arch = "aarch64"
+                target = f"{arch}-unknown-linux-musl"
+                cmds.append(
+                    (
+                        [
+                            "cargo",
+                            "build",
+                            "--package",
+                            CRATE_NAME,
+                            "--release",
+                            "--target",
+                            target,
+                            "--config",
+                            f'target.{target}.linker = "rust-lld"',
+                        ],
+                        {
+                            "cwd": project_dir,
+                            "env": os.environ
+                            | {
+                                "AYA_BUILD_EBPF": "true",
+                                "CC": f"{arch}-linux-musl-gcc",
+                            },
+                        },
+                    )
+                )
+
+        for cmd, kwargs in cmds:
+            print(f"Running command: {' '.join(cmd)} with kwargs: {kwargs}")
+            subprocess.check_call(cmd, **kwargs)
+
+        if platform.system() == "Linux":
+
+            async def run():
+                async with asyncio.create_subprocess_exec(
+                    "cargo",
+                    "xtask",
+                    "run",
+                    cwd=project_dir,
+                    stdin=subprocess.DEVNULL,
+                    stdout=asyncio.subprocess.PIPE,
+                    text=True,
+                ) as process:
+                    async with asyncio.timeout(30):
+                        for line in process.stdout:
+                            sys.stdout.write(line)
+                            if "Waiting for Ctrl-C" in line:
+                                process.send_signal(signal.SIGINT)
+                        retcode = await process.wait()
+                        if retcode != 0:
+                            raise subprocess.CalledProcessError(retcode, process.args)
+
+            asyncio.run(run())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test.sh b/test.sh
deleted file mode 100755
index 0d39c59..0000000
--- a/test.sh
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/usr/bin/env bash
-
-set -eux
-
-TEMPLATE_DIR=$1
-if [ -z "${TEMPLATE_DIR}" ]; then echo "template dir required"; exit 1; fi
-PROG_TYPE=$2
-if [ -z "${PROG_TYPE}" ]; then echo "program type required"; exit 1; fi
-CRATE_NAME=aya-test-crate
-
-case "${PROG_TYPE}" in
-    "cgroup_sockopt")
-	    ADDITIONAL_ARGS=(-d sockopt_target=getsockopt)
-        ;;
-    "classifier"|"cgroup_skb")
-        ADDITIONAL_ARGS=(-d direction=Ingress)
-        ;;
-    "fentry"|"fexit")
-        ADDITIONAL_ARGS=(-d fn_name=try_to_wake_up)
-        ;;
-    "kprobe"|"kretprobe")
-        ADDITIONAL_ARGS=(-d kprobe=do_unlinkat)
-        ;;
-    "lsm")
-        ADDITIONAL_ARGS=(-d lsm_hook=file_open)
-        ;;
-    "raw_tracepoint")
-        ADDITIONAL_ARGS=(-d tracepoint_name=sys_enter)
-        ;;
-    "sk_msg")
-        ADDITIONAL_ARGS=(-d sock_map=SOCK_MAP)
-        ;;
-    "tp_btf")
-	    ADDITIONAL_ARGS=(-d tracepoint_name=net_dev_queue)
-        ;;
-    "tracepoint")
-	    ADDITIONAL_ARGS=(-d tracepoint_category=net -d tracepoint_name=net_dev_queue)
-        ;;
-    "uprobe"|"uretprobe")
-        ADDITIONAL_ARGS=(-d uprobe_target=/proc/self/exe -d uprobe_fn_name=main)
-        ;;
-    *)
-        ADDITIONAL_ARGS=()
-esac
-
-TMP_DIR=$(mktemp -d)
-clean_up() {
-    # shellcheck disable=SC2317
-    rm -rf "${TMP_DIR}"
-}
-trap clean_up EXIT
-
-pushd "${TMP_DIR}"
-cargo generate --path "${TEMPLATE_DIR}" -n "${CRATE_NAME}" -d program_type="${PROG_TYPE}" "${ADDITIONAL_ARGS[@]}"
-pushd "${CRATE_NAME}"
-
-cargo +nightly fmt --all -- --check
-cargo build --package "${CRATE_NAME}"
-cargo build --package "${CRATE_NAME}" --release
-# We cannot run clippy over the whole workspace at once due to feature unification. Since both
-# ${CRATE_NAME} and ${CRATE_NAME}-ebpf depend on ${CRATE_NAME}-common and ${CRATE_NAME} activates
-# ${CRATE_NAME}-common's aya dependency, we end up trying to compile the panic handler twice: once
-# from the bpf program, and again from std via aya.
-cargo clippy --exclude "${CRATE_NAME}-ebpf" --all-targets --workspace -- --deny warnings
-cargo clippy --package "${CRATE_NAME}-ebpf" --all-targets -- --deny warnings
-
-expect << EOF
-  set timeout 30        ;# Increase timeout if necessary
-  spawn cargo xtask run
-  expect {
-    -re "Waiting for Ctrl-C.*" {
-      send -- \003      ;# Send Ctrl-C
-    }
-    timeout {
-      puts "Error: Timed out waiting for 'Waiting for Ctrl-C...'"
-      exit 1
-    }
-    eof {
-      puts "Error: Process exited prematurely"
-      exit 1
-    }
-  }
-
-  expect {
-    -re "Exiting.*" { }
-    eof { }
-  }
-EOF