diff --git a/.github/workflows/build.python.yml b/.github/workflows/build.python.yml index 5c8644a3..36b4af8a 100644 --- a/.github/workflows/build.python.yml +++ b/.github/workflows/build.python.yml @@ -24,6 +24,32 @@ on: required: false type: string default: "ubuntu-latest" + uv-workspace: + required: false + type: boolean + default: false + description: >- + Set to true if `working-directory` is a member of a uv workspace + (i.e. it has local path dependencies on sibling packages declared via + `tool.uv.sources` with `workspace = true`, such as other packages + under a monorepo's `libs/` directory). When enabled, all workspace + members are built together and the package's local (workspace) + dependencies are installed from the resulting wheels instead of + being resolved from a package index. This avoids failures for + private, unpublished packages, and avoids name collisions with + unrelated public packages that happen to share the same name. + Requires the uv workspace root (the `pyproject.toml`/`uv.lock` + declaring `[tool.uv.workspace]`) to be at the repository root. + python-platform: + required: false + type: string + default: "" + description: >- + Optional target platform to install dependencies for, passed to + `uv pip install --python-platform`. Useful when the build runner's + architecture differs from the deployment target's, e.g. + "aarch64-manylinux_2_28" when building on an x86_64 runner for an + arm64 Lambda. outputs: artifact-name: @@ -47,19 +73,98 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - cache-dependency-glob: "${{ inputs.working-directory }}/uv.lock" + cache-dependency-glob: "${{ inputs.uv-workspace && 'uv.lock' || format('{0}/uv.lock', inputs.working-directory) }}" - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ inputs.python_version }} - name: Install dependencies + if: ${{ !inputs.uv-workspace }} working-directory: ${{ inputs.working-directory }} run: | uv build - uv pip install dist/*.whl --target ./out + uv pip install dist/*.whl --target ./out ${{ inputs.python-platform != '' && format('--python-platform {0}', inputs.python-platform) || '' }} find ./out/ -name "__pycache__" -type d -exec rm -rf {} \; + - name: Install dependencies (uv workspace) + if: ${{ inputs.uv-workspace }} + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + PYTHON_PLATFORM: ${{ inputs.python-platform }} + run: | + set -euo pipefail + + if ! python3 -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"; then + echo "uv-workspace: true requires python_version >= 3.11 (tomllib is stdlib only from 3.11)." >&2 + exit 1 + fi + + # `uv sync`/`uv build --all-packages` operate on the uv workspace + # found from the current directory upwards, and we always run them + # from the repo root (see below) - so the workspace root must be + # the repo root itself, not some nested directory. + if [ ! -f "uv.lock" ] || ! grep -q '^\[tool\.uv\.workspace\]' pyproject.toml 2>/dev/null; then + echo "uv-workspace: true requires the uv workspace root (pyproject.toml with [tool.uv.workspace], and uv.lock) to be at the repository root." >&2 + exit 1 + fi + + # `uv build`/`uv export` for a workspace member resolve the workspace + # root themselves, but write relative output (e.g. `dist/`) relative + # to the current directory, so we always run from the repo root to + # get a single, predictable location for every workspace member's + # wheel. + PKG_NAME="$(python3 -c "import tomllib; print(tomllib.load(open('${WORKING_DIRECTORY}/pyproject.toml','rb'))['project']['name'])")" + + # Creates the uv environment without updating or changing uv.lock + # Build every workspace member once, so local (path) dependencies of + # this package are available as real wheels alongside it. + uv sync --frozen + uv build --all-packages --wheel + + # Pin every *third-party* dependency to the exact resolved version + # from the lockfile. Workspace members are intentionally excluded + # here (`--no-emit-workspace`) - they are installed explicitly from + # local wheels below instead, since they aren't published to any + # index and their names could otherwise collide with unrelated + # public packages. + uv export --frozen --package "$PKG_NAME" --no-emit-workspace --no-dev --no-hashes -o /tmp/uv-workspace-constraints.txt + + # Discover which sibling workspace packages this package actually + # depends on (directly or transitively), so we install exactly the + # matching local wheels and nothing else. Note: `uv export` also + # emits an `-e ` entry for $PKG_NAME itself (not just its + # workspace dependencies), so the loop below picks up and installs + # its own freshly-built wheel too - there's no separate, explicit + # install of $PKG_NAME's wheel anywhere in this script. + uv export --frozen --package "$PKG_NAME" --no-dev --no-hashes -o /tmp/uv-workspace-full-export.txt + + LOCAL_WHEELS=() + while IFS= read -r local_dir; do + [ -z "$local_dir" ] && continue + local_name="$(python3 -c "import tomllib; print(tomllib.load(open('${local_dir}/pyproject.toml','rb'))['project']['name'])")" + # Wheel filenames use the normalized distribution name: lowercased, + # with runs of `-`, `_` and `.` collapsed to a single `_`. + normalized_name="$(echo "$local_name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[-_.]+/_/g')" + wheel="$(find dist -maxdepth 1 -type f -name "${normalized_name}-*.whl" -print -quit)" + if [ -z "$wheel" ]; then + echo "No wheel found for workspace package ${local_name}" >&2 + exit 1 + fi + LOCAL_WHEELS+=("$wheel") + done < <(awk '$1 == "-e" { print $2 }' /tmp/uv-workspace-full-export.txt) + + mkdir -p "${WORKING_DIRECTORY}/out" + + INSTALL_ARGS=(-c /tmp/uv-workspace-constraints.txt --target "${WORKING_DIRECTORY}/out") + if [ -n "$PYTHON_PLATFORM" ]; then + INSTALL_ARGS+=(--python-platform "$PYTHON_PLATFORM") + fi + + uv pip install "${INSTALL_ARGS[@]}" "${LOCAL_WHEELS[@]}" + + find "${WORKING_DIRECTORY}/out" -name "__pycache__" -type d -exec rm -rf {} \; + - name: Validate and build artifact name id: artifact-name run: |