#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
PYPROJECT_PATH="${PYPROJECT_PATH:-${REPO_ROOT}/pyproject.toml}"
DEFAULT_SKILLS_PATH="skills"
SKILLS_PATH="${SKILLS_PATH:-${SKILLS_SUBMODULE_PATH:-}}"
SKILLS_DIR=""
REQUIRE_SKILLS_PAYLOAD="${REQUIRE_SKILLS_PAYLOAD:-auto}"
VERIFY_WHEEL_INSTALL="${VERIFY_WHEEL_INSTALL:-0}"
SMOKE_IMPORT_MODULE="${SMOKE_IMPORT_MODULE:-}"
SMOKE_RESOURCE_PATH="${SMOKE_RESOURCE_PATH:-resources/configs/default/config.mcp.json}"
SMOKE_SKILL_PATH="${SMOKE_SKILL_PATH:-resources/configs/default/skills/README.md}"
MSAGENT_BUNDLE_WEB_UI="${MSAGENT_BUNDLE_WEB_UI:-0}"
PROJECT_NAME=""
REQUIRES_PYTHON=""
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=11
sync_project_version() {
local python_bin="$1"
local sync_script="${REPO_ROOT}/scripts/sync_version.py"
log "Syncing package version from WHL_VERSION env or version.info..."
"${python_bin}" "${sync_script}"
}
log() {
printf '[build_whl] %s\n' "$*"
}
fail() {
printf '[build_whl] %s\n' "$*" >&2
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
is_truthy_flag() {
local value
value="${1:-}"
value="${value,,}"
case "${value}" in
""|0|false|no|off) return 1 ;;
*) return 0 ;;
esac
}
load_project_metadata() {
local python_bin="$1"
local metadata
[[ -f "${PYPROJECT_PATH}" ]] || fail "Missing ${PYPROJECT_PATH}. Cannot determine build metadata."
metadata="$("${python_bin}" - "${PYPROJECT_PATH}" "${REPO_ROOT}" <<'PY'
import pathlib
import re
import sys
pyproject_path = pathlib.Path(sys.argv[1])
repo_root = pathlib.Path(sys.argv[2])
text = pyproject_path.read_text(encoding="utf-8")
name = ""
requires_python = ""
entry_module = ""
try:
import tomllib # py311+
data = tomllib.loads(text)
project = data.get("project", {})
if isinstance(project, dict):
raw_name = project.get("name")
if isinstance(raw_name, str):
name = raw_name.strip()
raw_requires = project.get("requires-python")
if isinstance(raw_requires, str):
requires_python = raw_requires.strip()
scripts = project.get("scripts")
if isinstance(scripts, dict) and scripts:
raw_entry = next(iter(scripts.values()))
if isinstance(raw_entry, str):
entry_module = raw_entry.split(":", 1)[0].strip()
except Exception:
pass
if not name:
match = re.search(r'(?m)^name\s*=\s*"([^"]+)"', text)
if match:
name = match.group(1).strip()
if not requires_python:
match = re.search(r'(?m)^requires-python\s*=\s*"([^"]+)"', text)
if match:
requires_python = match.group(1).strip()
if not entry_module:
block = re.search(r'(?ms)^\[project\.scripts\]\s*(.+?)(?:^\[|\Z)', text)
if block:
entry = re.search(r'(?m)^[^#\n=]+\s*=\s*"([^":]+)', block.group(1))
if entry:
entry_module = entry.group(1).strip()
if not name:
name = repo_root.name
major = 3
minor = 11
match = re.search(r'>=\s*([0-9]+)(?:\.([0-9]+))?', requires_python or "")
if match:
major = int(match.group(1))
minor = int(match.group(2) or 0)
print(f"PROJECT_NAME={name}")
print(f"REQUIRES_PYTHON={requires_python}")
print(f"MIN_PYTHON_MAJOR={major}")
print(f"MIN_PYTHON_MINOR={minor}")
print(f"SMOKE_IMPORT_MODULE={entry_module}")
PY
)" || fail "Failed to parse project metadata from ${PYPROJECT_PATH}"
while IFS='=' read -r key value; do
value="${value%$'\r'}"
case "${key}" in
PROJECT_NAME) PROJECT_NAME="${value}" ;;
REQUIRES_PYTHON) REQUIRES_PYTHON="${value}" ;;
MIN_PYTHON_MAJOR) MIN_PYTHON_MAJOR="${value}" ;;
MIN_PYTHON_MINOR) MIN_PYTHON_MINOR="${value}" ;;
SMOKE_IMPORT_MODULE)
if [[ -z "${SMOKE_IMPORT_MODULE}" ]]; then
SMOKE_IMPORT_MODULE="${value}"
fi
;;
esac
done <<< "${metadata}"
}
resolve_python() {
if [[ -n "${PYTHON_BIN:-}" ]]; then
printf '%s\n' "${PYTHON_BIN}"
return
fi
if command_exists python3; then
printf 'python3\n'
return
fi
if command_exists python; then
printf 'python\n'
return
fi
fail "Python interpreter not found. Set PYTHON_BIN or install python3/python."
}
ensure_supported_python() {
local python_bin="$1"
"${python_bin}" - "${MIN_PYTHON_MAJOR}" "${MIN_PYTHON_MINOR}" "${PROJECT_NAME}" <<'PY'
import sys
required_major = int(sys.argv[1])
required_minor = int(sys.argv[2])
project_name = sys.argv[3] or "this project"
if sys.version_info < (required_major, required_minor):
raise SystemExit(
f"{project_name} requires Python {required_major}.{required_minor}+ to build wheels."
)
PY
}
detect_skills_path() {
if [[ -n "${SKILLS_PATH}" ]]; then
SKILLS_DIR="${REPO_ROOT}/${SKILLS_PATH}"
return
fi
if [[ -d "${REPO_ROOT}/${DEFAULT_SKILLS_PATH}" ]]; then
SKILLS_PATH="${DEFAULT_SKILLS_PATH}"
SKILLS_DIR="${REPO_ROOT}/${SKILLS_PATH}"
fi
}
ensure_skills_payload() {
local required
case "${REQUIRE_SKILLS_PAYLOAD}" in
1|true|required) required=1 ;;
0|false|optional|off) required=0 ;;
auto|"")
if [[ -n "${SKILLS_PATH}" ]] || [[ -d "${REPO_ROOT}/${DEFAULT_SKILLS_PATH}" ]]; then
required=1
else
required=0
fi
;;
*)
fail "Invalid REQUIRE_SKILLS_PAYLOAD='${REQUIRE_SKILLS_PAYLOAD}'. Use auto|required|optional|off."
;;
esac
if [[ -z "${SKILLS_PATH}" ]]; then
if [[ "${required}" == "1" ]]; then
fail "Skills payload is required but no skills directory was detected. Set SKILLS_PATH or create ${DEFAULT_SKILLS_PATH}/."
fi
log "No skills payload configured; skipping skills payload checks."
return
fi
if [[ ! -d "${SKILLS_DIR}" ]]; then
if [[ "${required}" == "1" ]]; then
fail "Missing ${SKILLS_PATH}. Ensure the merged skills directory exists before building."
fi
log "Optional skills payload ${SKILLS_PATH} was not found; skipping."
return
fi
if ! find "${SKILLS_DIR}" -mindepth 1 -print -quit 2>/dev/null | grep -q .; then
if [[ "${required}" == "1" ]]; then
fail "${SKILLS_PATH} is empty. Add bundled skills before building."
fi
log "Optional skills payload ${SKILLS_PATH} is empty; skipping."
fi
}
prepare_dist_dir() {
mkdir -p "${DIST_DIR}"
find "${DIST_DIR}" -maxdepth 1 -type f \( -name '*.whl' -o -name '*.tar.gz' \) -delete
}
build_wheel() {
local python_bin="$1"
if command_exists uv; then
if [[ -f "${REPO_ROOT}/uv.lock" ]]; then
log "Checking uv.lock is in sync with pyproject.toml..."
if ! (
cd "${REPO_ROOT}"
uv lock --check
); then
fail "uv.lock is out of sync with pyproject.toml. If you changed project.version, dependencies, or dependency-groups, run 'uv lock', commit the updated uv.lock, and rerun the build."
fi
else
log "uv.lock not found; skipping uv lock consistency check."
fi
log "Building wheel with uv..."
(
cd "${REPO_ROOT}"
uv build --wheel --out-dir "${DIST_DIR}"
)
return
fi
log "uv not found, falling back to python -m build..."
"${python_bin}" -m pip install --upgrade build
"${python_bin}" -m build --wheel --outdir "${DIST_DIR}" "${REPO_ROOT}"
}
configure_build_flags() {
export MSAGENT_BUNDLE_WEB_UI
if is_truthy_flag "${MSAGENT_BUNDLE_WEB_UI}"; then
log "Bundling web UI payload: enabled (MSAGENT_BUNDLE_WEB_UI=${MSAGENT_BUNDLE_WEB_UI})"
else
log "Bundling web UI payload: disabled (set MSAGENT_BUNDLE_WEB_UI=1 to include it)"
fi
}
find_built_wheel() {
find "${DIST_DIR}" -maxdepth 1 -type f -name '*.whl' | sort | tail -n 1
}
resolve_venv_python() {
local venv_dir="$1"
if [[ -x "${venv_dir}/bin/python" ]]; then
printf '%s\n' "${venv_dir}/bin/python"
return
fi
if [[ -x "${venv_dir}/Scripts/python.exe" ]]; then
printf '%s\n' "${venv_dir}/Scripts/python.exe"
return
fi
fail "Could not find a Python executable inside ${venv_dir}."
}
verify_resource_in_installed_wheel() {
local python_bin="$1"
local work_dir="$2"
local project_name="$3"
local import_module_name="$4"
local resource_path="$5"
(
cd "${work_dir}"
"${python_bin}" - "${project_name}" "${import_module_name}" "${resource_path}" <<'PY'
import importlib
import importlib.metadata
import pathlib
import sys
from importlib.resources import files
project_name, import_module_name, resource_path = sys.argv[1:4]
if project_name:
importlib.metadata.version(project_name)
if import_module_name:
importlib.import_module(import_module_name)
if resource_path:
parts = [segment for segment in pathlib.PurePosixPath(resource_path.replace("\\", "/")).parts if segment not in (".", "")]
if parts:
resource = files(parts[0]).joinpath(*parts[1:])
if not resource.is_file():
raise SystemExit(f"resource check failed: {resource_path} not found in installed wheel")
resource.read_bytes()
print("wheel smoke test passed")
PY
)
}
verify_wheel_install() {
local python_bin="$1"
local wheel_path="$2"
local temp_dir
local venv_dir
[[ "${VERIFY_WHEEL_INSTALL}" == "1" ]] || return 0
temp_dir="$(mktemp -d)"
venv_dir="${temp_dir}/venv"
log "Running isolated wheel smoke test..."
"${python_bin}" -m venv "${venv_dir}"
VENV_PYTHON="$(resolve_venv_python "${venv_dir}")"
export VENV_PYTHON
"${VENV_PYTHON}" -m pip install --upgrade pip
"${VENV_PYTHON}" -m pip install "${wheel_path}"
verify_resource_in_installed_wheel "${VENV_PYTHON}" "${temp_dir}" "${PROJECT_NAME}" "${SMOKE_IMPORT_MODULE}" "${SMOKE_RESOURCE_PATH}"
verify_resource_in_installed_wheel "${VENV_PYTHON}" "${temp_dir}" "${PROJECT_NAME}" "${SMOKE_IMPORT_MODULE}" "${SMOKE_SKILL_PATH}"
rm -rf "${temp_dir}"
}
main() {
local python_bin
python_bin="$(resolve_python)"
sync_project_version "${python_bin}"
load_project_metadata "${python_bin}"
ensure_supported_python "${python_bin}"
detect_skills_path
configure_build_flags
if [[ -n "${SMOKE_RESOURCE_PATH}" ]] && [[ ! -f "${REPO_ROOT}/${SMOKE_RESOURCE_PATH}" ]]; then
log "Smoke resource path ${SMOKE_RESOURCE_PATH} does not exist in repository; skipping resource check."
SMOKE_RESOURCE_PATH=""
fi
log "Preparing wheel build in ${REPO_ROOT}"
log "Project: ${PROJECT_NAME} (requires-python: ${REQUIRES_PYTHON:-unknown})"
prepare_dist_dir
ensure_skills_payload
build_wheel "${python_bin}"
local wheel_path
wheel_path="$(find_built_wheel)"
if [[ -z "${wheel_path}" ]]; then
fail "Build failed: no wheel generated in ${DIST_DIR}"
fi
verify_wheel_install "${python_bin}" "${wheel_path}"
log "Wheel generated: ${wheel_path}"
log "Install with: pip install \"${wheel_path}\""
}
main "$@"