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
144 changes: 144 additions & 0 deletions .github/scripts/check-wheel-python-matrix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# Drift guard: fail when the release wheel-build interpreter matrix diverges
# from the Python support pyproject.toml declares.
#
# AAASM-4446 shipped rc3 with cp312-only wheels while pyproject claimed
# 3.12/3.13/3.14, so a 3.13 install silently fell back to a native-core-less
# sdist and governance registration became a no-op. The root cause was a
# hand-maintained build matrix drifting from `requires-python` + the Python
# classifiers with nothing checking they agree. This script is that check.
#
# It compares three facts and fails (non-zero) on any mismatch:
# 1. The "Programming Language :: Python :: 3.x" classifiers in pyproject.
# 2. The PYTHON_INTERPRETERS list in release-python.yml (the versions every
# wheel job actually builds for — see that file's single-source comment).
# 3. The `requires-python` lower bound in pyproject.
# The built set must equal the classifier set, and the requires-python floor
# must equal the lowest built version. It also fails if any wheel job hardcodes
# a literal `--interpreter <version>` list, bypassing the single source.
#
# Run locally:
# bash .github/scripts/check-wheel-python-matrix.sh
#
# Run from CI: see .github/workflows/wheel-python-matrix.yml (runs on every PR
# that touches pyproject.toml or the release workflow, not only at release).
#
# Usage (CLI, paths optional — default to the repo's files; overridable so the
# fixture suite can point at temp copies):
# .github/scripts/check-wheel-python-matrix.sh [pyproject.toml] [release-python.yml]
#
# Owner: python-sdk release pipeline (AAASM-4453).

# Print each "3.<minor>" declared by a Python classifier, numerically sorted,
# deduped. The bare ":: 3" classifier (no minor) is intentionally excluded.
extract_classifier_versions() {
local pyproject="$1"
grep -oE 'Programming Language :: Python :: 3\.[0-9]+' "$pyproject" \
| grep -oE '3\.[0-9]+' \
| sort -t. -k2,2n -u
}

# Print the lower-bound "3.<minor>" from `requires-python = ">=3.X,<4.0"`.
extract_requires_python_floor() {
local pyproject="$1"
grep -E '^[[:space:]]*requires-python[[:space:]]*=' "$pyproject" \
| grep -oE '>=[[:space:]]*3\.[0-9]+' \
| grep -oE '3\.[0-9]+' \
| head -n1
}

# Print each version in the workflow's PYTHON_INTERPRETERS env value,
# numerically sorted, deduped.
extract_built_interpreters() {
local workflow="$1"
grep -E '^[[:space:]]*PYTHON_INTERPRETERS[[:space:]]*:' "$workflow" \
| head -n1 \
| grep -oE "'[^']*'" \
| tr -d "'" \
| tr ' ' '\n' \
| grep -E '^3\.[0-9]+$' \
| sort -t. -k2,2n -u
}

# check_wheel_python_matrix <pyproject_path> <workflow_path>
# Returns 0 when the matrix matches declared support, non-zero otherwise.
check_wheel_python_matrix() {
local pyproject="${1:-pyproject.toml}"
local workflow="${2:-.github/workflows/release-python.yml}"
local rc=0

if [[ ! -f "$pyproject" ]]; then
echo "::error::pyproject not found: $pyproject" >&2
return 1
fi
if [[ ! -f "$workflow" ]]; then
echo "::error::release workflow not found: $workflow" >&2
return 1
fi

local classifiers built floor
classifiers="$(extract_classifier_versions "$pyproject")"
built="$(extract_built_interpreters "$workflow")"
floor="$(extract_requires_python_floor "$pyproject")"

# A parse miss must fail loud, never pass vacuously — an empty extraction
# would otherwise make two empty strings compare equal.
if [[ -z "$classifiers" ]]; then
echo "::error::no 'Programming Language :: Python :: 3.x' classifiers found in $pyproject" >&2
return 1
fi
if [[ -z "$built" ]]; then
echo "::error::no PYTHON_INTERPRETERS list found in $workflow" >&2
return 1
fi
if [[ -z "$floor" ]]; then
echo "::error::could not parse requires-python lower bound in $pyproject" >&2
return 1
fi

# Core drift check: the versions wheels are built for must equal the
# versions pyproject advertises support for.
if [[ "$classifiers" != "$built" ]]; then
{
echo "::error::wheel build matrix diverges from pyproject Python classifiers"
printf 'pyproject classifiers : %s\n' "$(echo "$classifiers" | paste -sd' ' -)"
printf 'built (PYTHON_INTERPRETERS): %s\n' "$(echo "$built" | paste -sd' ' -)"
} >&2
rc=1
fi

# A requires-python floor bump without dropping the matching classifier +
# wheel (or vice versa) is itself a support-claim drift — catch it.
local min_built
min_built="$(echo "$built" | head -n1)"
if [[ "$floor" != "$min_built" ]]; then
echo "::error::requires-python floor (>=$floor) != lowest built interpreter ($min_built)" >&2
rc=1
fi

# No wheel job may hardcode a literal `--interpreter <version>` list: that
# would let one platform silently drift from the single source of truth,
# which is exactly the failure mode this guard exists to prevent.
local literal
literal="$(grep -nE -- '--interpreter[[:space:]]+[0-9]' "$workflow" || true)"
if [[ -n "$literal" ]]; then
{
echo "::error::a wheel job hardcodes --interpreter versions instead of \${{ env.PYTHON_INTERPRETERS }}:"
printf '%s\n' "$literal"
} >&2
rc=1
fi

if [[ "$rc" -eq 0 ]]; then
echo "OK: wheel build matrix matches pyproject Python support ($(echo "$built" | paste -sd' ' -)); requires-python floor >=$floor"
fi
return "$rc"
}

# When executed directly, run the check as a CLI. When sourced, define the
# functions only and let the caller (the fixture suite) drive them.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -uo pipefail
check_wheel_python_matrix "${1:-}" "${2:-}"
fi
141 changes: 141 additions & 0 deletions .github/scripts/test_check-wheel-python-matrix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# Unit tests for `.github/scripts/check-wheel-python-matrix.sh`.
#
# Drives the drift guard that keeps the release wheel matrix in sync with the
# Python support pyproject.toml declares (AAASM-4446 / AAASM-4453). Sources the
# same file CI runs, then exercises it against synthetic pyproject/workflow
# fixtures for both the passing and the drifting cases — plus one run against
# the repo's real files to prove the shipped config actually passes.
#
# Run locally:
# bash .github/scripts/test_check-wheel-python-matrix.sh
#
# Run from CI: see .github/workflows/wheel-python-matrix.yml.
#
# Exits 0 when every case passes, 1 otherwise.
#
# Refs AAASM-4453.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# shellcheck source=.github/scripts/check-wheel-python-matrix.sh
source "${SCRIPT_DIR}/check-wheel-python-matrix.sh"

# Literal GitHub Actions env reference — kept in a single-quoted variable so
# bash never tries to expand `${{ ... }}`. The non-expansion is the point here.
# shellcheck disable=SC2016
ENVREF='${{ env.PYTHON_INTERPRETERS }}'

WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT

pass=0
fail=0
total=0

# assert_exit <label> <expected_rc> <actual_rc>
assert_exit() {
local label="$1" expected="$2" actual="$3"
total=$((total + 1))
if [[ "$expected" == "$actual" ]]; then
printf 'OK %-52s rc=%s\n' "$label" "$actual"
pass=$((pass + 1))
else
printf 'FAIL %-52s expected rc=%s actual rc=%s\n' "$label" "$expected" "$actual" >&2
fail=$((fail + 1))
fi
}

# write_pyproject <path> <requires-python> <classifier-minor>...
write_pyproject() {
local path="$1" requires="$2"; shift 2
{
echo '[project]'
echo "requires-python = \"$requires\""
echo 'classifiers = ['
echo ' "Programming Language :: Python :: 3",'
local v
for v in "$@"; do
echo " \"Programming Language :: Python :: $v\","
done
echo ']'
} > "$path"
}

# write_workflow <path> <PYTHON_INTERPRETERS value> [interpreter-arg]
# interpreter-arg defaults to the env reference (the correct, non-drifting form).
write_workflow() {
local path="$1" interpreters="$2" arg="${3:-$ENVREF}"
{
echo 'env:'
echo " PYTHON_INTERPRETERS: '$interpreters'"
echo 'jobs:'
echo ' build-macos:'
echo ' steps:'
echo " - run: maturin build --release --interpreter $arg"
} > "$path"
}

# run_guard <pyproject> <workflow> -> echoes the guard's exit code
run_guard() {
check_wheel_python_matrix "$1" "$2" >/dev/null 2>&1
echo "$?"
}

echo "== real repo files (shipped config must pass) =="
assert_exit "real pyproject.toml + release-python.yml" 0 \
"$(run_guard "${REPO_ROOT}/pyproject.toml" "${REPO_ROOT}/.github/workflows/release-python.yml")"

echo "== synthetic: matching matrix passes =="
write_pyproject "${WORKDIR}/ok.toml" ">=3.12,<4.0" 3.12 3.13 3.14
write_workflow "${WORKDIR}/ok.yml" "3.12 3.13 3.14"
assert_exit "classifiers == built, floor == min" 0 \
"$(run_guard "${WORKDIR}/ok.toml" "${WORKDIR}/ok.yml")"

echo "== synthetic: drift is caught =="
# Classifier claims a version the matrix does not build (the AAASM-4446 shape).
write_pyproject "${WORKDIR}/extra_classifier.toml" ">=3.12,<4.0" 3.12 3.13 3.14
write_workflow "${WORKDIR}/short_matrix.yml" "3.12"
assert_exit "classifiers superset of built (cp313/cp314 unbuilt)" 1 \
"$(run_guard "${WORKDIR}/extra_classifier.toml" "${WORKDIR}/short_matrix.yml")"

# Matrix builds a version pyproject does not advertise.
write_pyproject "${WORKDIR}/narrow_classifier.toml" ">=3.12,<4.0" 3.12 3.13
write_workflow "${WORKDIR}/wide_matrix.yml" "3.12 3.13 3.14"
assert_exit "built superset of classifiers" 1 \
"$(run_guard "${WORKDIR}/narrow_classifier.toml" "${WORKDIR}/wide_matrix.yml")"

echo "== synthetic: requires-python floor drift is caught =="
# Floor says >=3.13 but 3.12 is still built + classified.
write_pyproject "${WORKDIR}/floor.toml" ">=3.13,<4.0" 3.12 3.13
write_workflow "${WORKDIR}/floor.yml" "3.12 3.13"
assert_exit "requires-python floor > lowest built version" 1 \
"$(run_guard "${WORKDIR}/floor.toml" "${WORKDIR}/floor.yml")"

echo "== synthetic: hardcoded --interpreter literal is caught =="
write_pyproject "${WORKDIR}/lit.toml" ">=3.12,<4.0" 3.12 3.13 3.14
write_workflow "${WORKDIR}/lit.yml" "3.12 3.13 3.14" "3.12 3.13 3.14"
assert_exit "job hardcodes --interpreter versions" 1 \
"$(run_guard "${WORKDIR}/lit.toml" "${WORKDIR}/lit.yml")"

echo "== synthetic: unparseable inputs fail loud, not vacuously pass =="
# pyproject with only the bare ":: 3" classifier (no minor versions).
write_pyproject "${WORKDIR}/noclass.toml" ">=3.12,<4.0"
write_workflow "${WORKDIR}/noclass.yml" "3.12"
assert_exit "no Python minor classifiers" 1 \
"$(run_guard "${WORKDIR}/noclass.toml" "${WORKDIR}/noclass.yml")"

# workflow with no PYTHON_INTERPRETERS env at all.
write_pyproject "${WORKDIR}/nolist.toml" ">=3.12,<4.0" 3.12
printf 'env:\n OTHER: x\n' > "${WORKDIR}/nolist.yml"
assert_exit "no PYTHON_INTERPRETERS in workflow" 1 \
"$(run_guard "${WORKDIR}/nolist.toml" "${WORKDIR}/nolist.yml")"

echo
echo "Summary: ${pass} passed, ${fail} failed, ${total} total"
if (( fail > 0 )); then
exit 1
fi
Loading