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
1 change: 1 addition & 0 deletions .github/workflows/publish-recipe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ on:
options:
- llvm-asan
- llvm-msan
- cpython-asan
- cpython-debug
version:
type: string
Expand Down
167 changes: 167 additions & 0 deletions actions/setup-cpython/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
name: 'Setup CPython'
description: |
Resolves a sanitised CPython by `flavor`. Each flavor maps to a
cpython-* recipe; the install lands at $GITHUB_WORKSPACE/install
(same layout setup-llvm uses, so a matrix row can stack both
actions without colliding on prefix). Flavor table:

'asan' Recipe cpython-asan. Address+Undefined sanitised.
'debug' Recipe cpython-debug. Py_DEBUG / trace-refs.

The two are mutually exclusive at venv level (sanitised and
debug ABIs are not interchangeable, and an extension built
against one libpython will not load into the other). Consumers
pick exactly one per matrix row.

Side effects on $GITHUB_PATH / $GITHUB_ENV:
PATH gets <prefix>/bin prepended so `python<M.N>`
resolves without per-step plumbing.
LD_LIBRARY_PATH <prefix>/lib so --enable-shared finds
libpython3.<N>.so.
Python3_ROOT_DIR <prefix> so `find_package(Python3)` resolves
without consumer hint.
PYTHON_EXECUTABLE <prefix>/bin/python<M.N>; convention used by
autotools / scikit-build / setuptools.
ASAN_OPTIONS=detect_leaks=0 (asan flavor only) — Python's
intentional interning at shutdown would
otherwise exit 23 before consumer assertions
get a chance to run.

inputs:
version:
required: true
description: |
CPython version, e.g. "3.14.3". Must match a cell in
cells.yaml for the chosen flavor.
os:
required: true
description: |
Runner-image slug for the cache key (e.g. "ubuntu-24.04").
Must match what publish-recipe used.
arch:
required: false
default: ''
description: |
Architecture slug; empty derives from the `os` slug using the
same mapping setup-llvm applies (macos-N-intel → x86_64,
macos-N → arm64, *-arm → arm64, else x86_64).
flavor:
required: true
description: |
Selects the CPython source. One of: 'asan' (cpython-asan
recipe), 'debug' (cpython-debug recipe). No default — the
sanitised builds have visibly different ABIs and a silent
fall-through would mask a matrix typo.
build-on-miss:
required: false
default: 'false'
description: |
Forwarded to setup-recipe. Default 'false' — fail fast with
a publish-recipe hint when the cell is unwarmed; rebuilding
CPython inline on every PR defeats the cache.
ref:
required: false
default: 'main'
description: 'ci-workflows ref for setup-recipe to read recipes/ from.'
cache-base:
required: false
default: ''
description: |
Forwarded to setup-recipe. Empty uses the upstream Releases
cache; set to file:///abs/path or https://lab.local/recipes/
to point at a private backend.

outputs:
source:
description: '"recipe-cache" | "inline-build"'
value: ${{ steps.finalize.outputs.source }}
prefix:
description: 'Install prefix (always $GITHUB_WORKSPACE/install).'
value: ${{ steps.finalize.outputs.prefix }}
python:
description: 'Path to the python<M.N> executable inside the install.'
value: ${{ steps.finalize.outputs.python }}
python-version:
description: 'Major.minor of the installed Python, e.g. "3.14".'
value: ${{ steps.finalize.outputs.python-version }}

runs:
using: composite
steps:
- name: Resolve flavor → recipe
id: resolve
shell: bash
env:
FLAVOR: ${{ inputs.flavor }}
ARCH_IN: ${{ inputs.arch }}
OS: ${{ inputs.os }}
run: |
set -euo pipefail
case "${FLAVOR}" in
asan) recipe=cpython-asan ;;
debug) recipe=cpython-debug ;;
*) echo "::error::unknown flavor '${FLAVOR}' (expected: 'asan', 'debug')" >&2
exit 1 ;;
esac
# Arch derivation mirrors setup-llvm exactly so the two
# actions are interchangeable from a matrix's point of view.
if [[ -n "${ARCH_IN}" ]]; then
arch="${ARCH_IN}"
else
case "${OS}" in
macos-*-intel) arch=x86_64 ;;
macos-*) arch=arm64 ;;
*-arm) arch=arm64 ;;
*) arch=x86_64 ;;
esac
fi
echo "recipe=$recipe" >> "$GITHUB_OUTPUT"
echo "arch=$arch" >> "$GITHUB_OUTPUT"

- name: Recipe (cache or inline build)
id: recipe
uses: compiler-research/ci-workflows/actions/setup-recipe@main
with:
recipe: ${{ steps.resolve.outputs.recipe }}
version: ${{ inputs.version }}
os: ${{ inputs.os }}
arch: ${{ steps.resolve.outputs.arch }}
build-on-miss: ${{ inputs.build-on-miss }}
ref: ${{ inputs.ref }}
cache-base: ${{ inputs.cache-base }}

- name: Finalize outputs + env
id: finalize
shell: bash
env:
FLAVOR: ${{ inputs.flavor }}
VERSION: ${{ inputs.version }}
RECIPE_HIT: ${{ steps.recipe.outputs.cache-hit }}
run: |
set -euo pipefail
prefix="${GITHUB_WORKSPACE}/install"
major_minor="$(echo "${VERSION}" | awk -F. '{print $1"."$2}')"
python="${prefix}/bin/python${major_minor}"
# Fail loudly if the recipe's install layout drifted; a
# missing launcher is a contract break, not a soft warning.
if [[ ! -x "$python" ]]; then
echo "::error::expected $python after recipe install" >&2
exit 1
fi
if [[ "${RECIPE_HIT}" == "true" ]]; then
src=recipe-cache
else
src=inline-build
fi
echo "${prefix}/bin" >> "$GITHUB_PATH"
echo "LD_LIBRARY_PATH=${prefix}/lib" >> "$GITHUB_ENV"
echo "Python3_ROOT_DIR=${prefix}" >> "$GITHUB_ENV"
echo "PYTHON_EXECUTABLE=${python}" >> "$GITHUB_ENV"
if [[ "${FLAVOR}" == "asan" ]]; then
echo "ASAN_OPTIONS=detect_leaks=0" >> "$GITHUB_ENV"
fi
echo "source=${src}" >> "$GITHUB_OUTPUT"
echo "prefix=${prefix}" >> "$GITHUB_OUTPUT"
echo "python=${python}" >> "$GITHUB_OUTPUT"
echo "python-version=${major_minor}" >> "$GITHUB_OUTPUT"
echo "::notice title=setup-cpython::flavor=${FLAVOR} source=${src} python=${python}"
5 changes: 5 additions & 0 deletions cells.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ cells:
# platform package manager; the CI cost of building debug Python on
# those isn't yet justified.
- { recipe: cpython-debug, version: '3.14.3', os: ubuntu-24.04, arch: x86_64 }
# cpython-asan: --with-address-sanitizer + --with-undefined-behavior-sanitizer.
# Linux x86_64 only -- macOS would need an asan-instrumented libc++
# from llvm-asan in scope (cppyy uses libc++ there), which the
# Linux row sidesteps because cppyy uses libstdc++.
- { recipe: cpython-asan, version: '3.14.3', os: ubuntu-24.04, arch: x86_64 }
# llvm-release: vanilla LLVM/Clang for the latest major across every
# supported runner image. setup-llvm's default flavor pulls these
# cells; consumers asking for older majors pick `flavor: system`,
Expand Down
125 changes: 125 additions & 0 deletions recipes/cpython-asan/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Builds an asan+ubsan-instrumented CPython install tree.

Configure flags drive the diagnostic surface:
--with-address-sanitizer -fsanitize=address through
BASECFLAGS / PY_LDFLAGS; every
stdlib C extension built by
Modules/Setup inherits the flag.
--with-undefined-behavior-sanitizer -fsanitize=undefined likewise.
--enable-shared consumers link libpython3.<N>.so.
--disable-test-modules in-tree test extensions are large
and unused; drops a few hundred MB.

System clang from install-build-deps drives the build. Downstream
consumers must match compiler family (gcc-asan vs clang-asan have
non-unifying runtimes); see recipe.yaml for the full caveat. No
`bootstrap:` declared -- standalone, identical scaffold to cpython-debug.

Inputs (env): see actions/lib/llvm_build.py docstring for the
RECIPE_VERSION / WORK_DIR / OUT_DIR / NCPUS contract.

Outputs (env, written to GITHUB_ENV when present):
SRC_COMMIT sha of cpython HEAD that was built.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR / ".." / ".." / "actions" / "lib"))

import llvm_build # noqa: E402


CPYTHON_REPO = "https://github.com/python/cpython.git"


def _verify_asan_build(python_bin: Path, install_prefix: Path) -> None:
"""Post-install assertion: asan + ubsan are genuinely wired in.

Catches the silent-regression failure mode where the configure
flags get removed but the cell still ships under the asan name.
`__asan_init` resolves in the main namespace iff the interpreter
was linked against the asan runtime; UBSan has no comparable
always-resident symbol, so we fall back to sysconfig.

LD_LIBRARY_PATH points at $prefix/lib so the launcher resolves
libpython3.<N>.so (off the default loader search path with
--enable-shared). ASAN_OPTIONS=detect_leaks=0 keeps the verify
from exiting 23 on Python's intentional interning at shutdown.
"""
env = {
**os.environ,
"LD_LIBRARY_PATH": str(install_prefix / "lib"),
"ASAN_OPTIONS": "detect_leaks=0",
}
out = subprocess.run(
[str(python_bin), "-c",
"import ctypes, sys, sysconfig; "
"assert hasattr(ctypes.CDLL(None), '__asan_init'), "
"'cell built without --with-address-sanitizer'; "
"cfl = sysconfig.get_config_var('PY_CFLAGS') or ''; "
"assert '-fsanitize=undefined' in cfl, "
"'cell built without --with-undefined-behavior-sanitizer'; "
"print(sys.version)"],
check=True, capture_output=True, text=True, env=env,
).stdout.strip()
print(f"build.py: asan+ubsan verify ok: {out}", flush=True)


def main() -> int:
llvm_build.setup_env()
work_dir = Path(os.environ["WORK_DIR"])
out_dir = Path(os.environ["OUT_DIR"])
version = os.environ["RECIPE_VERSION"]
ncpus = os.environ["NCPUS"]

src_dir = work_dir / "cpython"
install_prefix = out_dir / "install"

os.chdir(work_dir)
llvm_build.clone_shallow(CPYTHON_REPO, f"v{version}", src_dir)
src_commit = llvm_build.record_src_commit(src_dir)

install_prefix.parent.mkdir(parents=True, exist_ok=True)
if install_prefix.exists():
# configure / make install are idempotent but `--prefix=...`
# writes by overlay, leaving stale files from a prior run.
# Wipe the prefix so the cell content matches exactly what
# THIS build produced.
shutil.rmtree(install_prefix)

print(f"build.py: configuring asan+ubsan CPython {version} -> "
f"{install_prefix}", flush=True)
subprocess.run(
["./configure",
f"--prefix={install_prefix}",
"--with-address-sanitizer",
"--with-undefined-behavior-sanitizer",
"--enable-shared",
"--disable-test-modules"],
cwd=src_dir, check=True,
)

print(f"build.py: building (-j{ncpus})", flush=True)
subprocess.run(["make", "-j", ncpus], cwd=src_dir, check=True)

print("build.py: installing", flush=True)
subprocess.run(["make", "install"], cwd=src_dir, check=True)

major_minor = ".".join(version.split(".")[:2])
python_bin = install_prefix / "bin" / f"python{major_minor}"
_verify_asan_build(python_bin, install_prefix)

print(f"build.py: done. SRC_COMMIT={src_commit}", flush=True)
return 0


if __name__ == "__main__":
sys.exit(main())
22 changes: 22 additions & 0 deletions recipes/cpython-asan/recipe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
recipe: cpython-asan
description: |
CPython built with --with-address-sanitizer +
--with-undefined-behavior-sanitizer. Catches use-after-free /
heap-overflow / misaligned access at the Python↔C boundary --
the C++-only asan rows do not exercise PyObject lifetime,
refcount/proxy logic in `_cppyy.so`, or the wrapper-call paths
cppyy generates from Python type info.

Downstream consumers build their native extensions with
-fsanitize=address,undefined and load them into this
interpreter. The two asan runtimes unify only when the
downstream compiler family matches the one this cell was built
with (system clang on the standard runner image); mixing
gcc-asan with clang-asan trips the init-order check at load
time. Sanitised ABI is also incompatible with release ABI:
consumers pick exactly one CPython flavour per venv.

# Only fields read by build_manifest.py / publish-recipe live here.
source:
repo: https://github.com/python/cpython
branch_template: v{version}
Loading