diff --git a/.github/workflows/gadi-petsc-image.yml b/.github/workflows/gadi-petsc-image.yml new file mode 100644 index 000000000..ccaeffb9b --- /dev/null +++ b/.github/workflows/gadi-petsc-image.yml @@ -0,0 +1,171 @@ +name: "GHCR: Gadi PETSc Base Image" + +# Builds docs/developer/gadi_singularity/petsc.rhel — the from-source PETSc base +# that gadi-uw3-image.yml layers on. +# +# Triggers are deliberately narrow: this compiles PETSc with mumps, hypre, +# superlu_dist, mmg/parmmg, ptscotch and slepc, a build measured in hours, and +# only petsc.rhel and its patches can change the result. + +on: + push: + branches: + - main + - development + paths: + - 'docs/developer/gadi_singularity/petsc.rhel' + - 'petsc-custom/patches/**' + - '.github/workflows/gadi-petsc-image.yml' + workflow_dispatch: + inputs: + petsc_version: + description: 'PETSc version to build' + type: string + default: '3.25.0' + make_np: + description: 'PETSc build parallelism (--with-make-np)' + type: string + default: '4' + force_rebuild: + description: 'Force full rebuild (no cache)' + type: boolean + default: false + +concurrency: + group: gadi-petsc-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + # Measured at ~25 min with ~25% run-to-run variance, so this is ~5x headroom + # for a heavier future PETSc while still failing a hang in hours, not a day. + timeout-minutes: 120 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Measured: the whole build consumes ~7 GB against 86 GB free on a fresh + # runner, so no space needs reclaiming. Kept as instrumentation in case a + # future PETSc pulls in far more --download- packages. + - name: Report disk headroom + run: df -h / + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve names and tags + id: meta + run: | + set -euo pipefail + + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + IMAGE="ghcr.io/${OWNER}/petsc-gadi" + + PETSC_VERSION="${{ inputs.petsc_version }}" + PETSC_VERSION="${PETSC_VERSION:-3.25.0}" + MAKE_NP="${{ inputs.make_np }}" + MAKE_NP="${MAKE_NP:-4}" + + { + echo "image=${IMAGE}:${PETSC_VERSION}-ompi" + echo "tags=${IMAGE}:${PETSC_VERSION}-ompi,${IMAGE}:latest" + echo "petsc_version=${PETSC_VERSION}" + echo "make_np=${MAKE_NP}" + } >> "$GITHUB_OUTPUT" + + echo "Building ${IMAGE}:${PETSC_VERSION}-ompi with --with-make-np=${MAKE_NP}" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docs/developer/gadi_singularity/petsc.rhel + # Docker v2 media types — see the note in gadi-uw3-image.yml. + outputs: type=image,push=true,oci-mediatypes=false + platforms: linux/amd64 + # See the note in gadi-uw3-image.yml — attestations break some + # `singularity pull` versions. + provenance: false + sbom: false + no-cache: ${{ inputs.force_rebuild || false }} + build-args: | + PYTHON_VERSION=3.12 + PETSC_VERSION=${{ steps.meta.outputs.petsc_version }} + PETSC_MAKE_NP=${{ steps.meta.outputs.make_np }} + tags: ${{ steps.meta.outputs.tags }} + + - name: Report build size + run: | + docker image ls ${{ steps.meta.outputs.image }} || true + df -h / + + smoke-test: + needs: build-and-push + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + steps: + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull image + run: docker pull ${{ needs.build-and-push.outputs.image }} + + # petsc4py importing is the thing that actually breaks when the PETSc build + # goes subtly wrong (shared libraries, PYTHONPATH, the meson petsc4py step). + - name: Import petsc4py and slepc4py + run: | + docker run --rm ${{ needs.build-and-push.outputs.image }} python -c " + import petsc4py, slepc4py + petsc4py.init() + from petsc4py import PETSc + print('petsc4py', petsc4py.__version__) + print('slepc4py', slepc4py.__version__) + print('PETSc', PETSc.Sys.getVersion()) + " + + - name: MPI sanity (2 ranks) + run: | + docker run --rm ${{ needs.build-and-push.outputs.image }} \ + mpiexec -n 2 python -c " + from mpi4py import MPI + c = MPI.COMM_WORLD + print(f'rank {c.rank} of {c.size}') + assert c.size == 2 + " + + cleanup: + needs: [build-and-push, smoke-test] + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # Housekeeping only: see the note in gadi-uw3-image.yml. + - name: Delete old untagged image versions + continue-on-error: true + uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: petsc-gadi + package-type: container + min-versions-to-keep: 2 + delete-only-untagged-versions: true diff --git a/.github/workflows/gadi-uw3-image.yml b/.github/workflows/gadi-uw3-image.yml new file mode 100644 index 000000000..db9c97ea3 --- /dev/null +++ b/.github/workflows/gadi-uw3-image.yml @@ -0,0 +1,230 @@ +name: "GHCR: Gadi Singularity Image" + +# Builds docs/developer/gadi_singularity/underworld3.rhel and pushes to GHCR for +# `singularity pull docker://...` on Gadi. +# +# This is the layer that moves; the expensive PETSc base under it is built rarely +# by gadi-petsc-image.yml. Unlike docker-image.yaml and binder-image.yml, this +# image is Rocky Linux 8.10 + from-source PETSc, to match Gadi's ABI. + +on: + push: + branches: + - main + - development + tags: ['v*'] + paths: + - 'docs/developer/gadi_singularity/underworld3.rhel' + - '.github/workflows/gadi-uw3-image.yml' + - 'pixi.toml' + - 'pyproject.toml' + - 'setup.py' + - 'src/**' + # Releases are the guaranteed path for a tagged image: the `tags:` filter above + # is combined with `paths:`, so a tag whose diff touches nothing in that list + # would not build on its own. + release: + types: [published] + workflow_dispatch: + inputs: + uw3_branch: + description: 'UW3 branch/tag to clone and build (e.g. development, v3.1.0). Empty = triggering ref.' + type: string + default: '' + petsc_image: + description: 'PETSc base image. Empty = ghcr.io//petsc-gadi:3.25.0-ompi' + type: string + default: '' + image_tag: + description: 'Image tag override (e.g. ci-test). Empty = derive from ref.' + type: string + default: '' + force_rebuild: + description: 'Force full rebuild (no cache)' + type: boolean + default: false + +concurrency: + group: gadi-uw3-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve names and tags + id: meta + run: | + set -euo pipefail + + # Image names must be lowercase; repository_owner may not be. + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + IMAGE="ghcr.io/${OWNER}/underworld3-gadi" + + # Same ref-name / IS_RELEASE convention as binder-image.yml. + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + REF_NAME=${GITHUB_REF#refs/tags/} + IS_RELEASE=true + else + REF_NAME=${GITHUB_REF#refs/heads/} + IS_RELEASE=false + fi + + if [[ -n "${{ inputs.uw3_branch }}" ]]; then + REF_NAME="${{ inputs.uw3_branch }}" + [[ "$REF_NAME" == v* ]] && IS_RELEASE=true + fi + if [[ -n "${{ inputs.image_tag }}" ]]; then + DOCKER_TAG="${{ inputs.image_tag }}" + else + # Docker tags cannot contain '/' + DOCKER_TAG=$(echo "$REF_NAME" | tr '/' '-') + fi + + # Dispatch input wins, then the GADI_PETSC_IMAGE repo variable (lets a + # fork reuse a base image it already published under another name), + # then the canonical name. + PETSC_IMAGE="${{ inputs.petsc_image }}" + PETSC_IMAGE="${PETSC_IMAGE:-${{ vars.GADI_PETSC_IMAGE }}}" + PETSC_IMAGE="${PETSC_IMAGE:-ghcr.io/${OWNER}/petsc-gadi:3.25.0-ompi}" + + TAGS="${IMAGE}:${DOCKER_TAG}" + if [[ "$IS_RELEASE" == "true" ]]; then + TAGS="${TAGS},${IMAGE}:latest" + fi + + # A release tag is the version; anything else gets a PEP440 dev + # version carrying the commit, since branch names are not versions. + if [[ "$IS_RELEASE" == "true" ]]; then + UW3_VERSION="${REF_NAME#v}" + else + UW3_VERSION="0.0.0.dev0+g${GITHUB_SHA:0:7}" + fi + + { + echo "image=${IMAGE}:${DOCKER_TAG}" + echo "tags=${TAGS}" + echo "uw3_branch=${REF_NAME}" + echo "petsc_image=${PETSC_IMAGE}" + echo "uw3_version=${UW3_VERSION}" + } >> "$GITHUB_OUTPUT" + + echo "Building ${IMAGE}:${DOCKER_TAG}" + echo " UW3 ref: ${REF_NAME}" + echo " PETSc base: ${PETSC_IMAGE}" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docs/developer/gadi_singularity/underworld3.rhel + # Docker v2 media types rather than buildx's OCI default, matching the + # podman `--format docker` images already validated on Gadi. Older + # Singularity builds read OCI patchily; this removes the variable. + outputs: type=image,push=true,oci-mediatypes=false + # Gadi is x86_64. No arm64: it would need QEMU for no benefit, and the + # gmsh / vtk-osmesa wheels this image needs are x86_64-only anyway. + platforms: linux/amd64 + # Attestations turn the result into a multi-manifest index containing an + # "unknown/unknown" entry, which some Singularity/Apptainer versions + # refuse to pull. Keep these off — see README "Automated builds". + provenance: false + sbom: false + no-cache: ${{ inputs.force_rebuild || false }} + build-args: | + PYTHON_VERSION=3.12 + UW3_BRANCH=${{ steps.meta.outputs.uw3_branch }} + UW3_REPO=${{ github.server_url }}/${{ github.repository }}.git + PETSC_IMAGE=${{ steps.meta.outputs.petsc_image }} + SETUPTOOLS_SCM_PRETEND_VERSION=${{ steps.meta.outputs.uw3_version }} + tags: ${{ steps.meta.outputs.tags }} + # Cache lives in its own package: sharing one with the image put stale + # cache manifests into the same untagged pool the cleanup job prunes, + # so they competed with old images for min-versions-to-keep. + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/underworld3-gadi-buildcache:latest + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/underworld3-gadi-buildcache:latest,mode=max + + smoke-test: + needs: build-and-push + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + packages: read + steps: + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull image + run: docker pull ${{ needs.build-and-push.outputs.image }} + + # A bare import is the cheapest check and catches a missing runtime dep + # (this is exactly how the absent `requests` would have shown up). + - name: Import underworld3 + run: | + docker run --rm ${{ needs.build-and-push.outputs.image }} \ + python -c "import underworld3; print('underworld3', underworld3.__version__)" + + # The Containerfile installs gmsh and vtk-osmesa behind `|| echo skip`, so a + # failed install is silent. Import them explicitly or it ships broken. + - name: Import optional stack (gmsh, vtk, pyvista, h5py, petsc4py) + run: | + docker run --rm ${{ needs.build-and-push.outputs.image }} \ + python -c "import gmsh, vtk, pyvista, h5py, petsc4py; print('optional stack OK')" + + - name: MPI sanity (2 ranks) + run: | + docker run --rm ${{ needs.build-and-push.outputs.image }} \ + mpiexec -n 2 python -c " + import underworld3 as uw + from mpi4py import MPI + c = MPI.COMM_WORLD + assert c.size == 2 + print(f'rank {c.rank} of {c.size}, underworld3 {uw.__version__}') + " + + cleanup: + # Only prune once the image is known good — otherwise a broken build would + # delete the last working one. + needs: [build-and-push, smoke-test] + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # Housekeeping only: a failed prune must not fail a run whose image is + # good. The action 404s partway through on user-owned (vs org-owned) + # packages, having already deleted what it could. + - name: Delete old untagged image versions + continue-on-error: true + uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: underworld3-gadi + package-type: container + min-versions-to-keep: 3 + # Load-bearing: every TAGGED image (:latest, :v3.1.0, :main, and the + # current :development) is exempt regardless of age. Only superseded + # rolling builds are eligible. + delete-only-untagged-versions: true diff --git a/docs/developer/gadi_singularity/README.md b/docs/developer/gadi_singularity/README.md index 6b579a451..46a00453f 100644 --- a/docs/developer/gadi_singularity/README.md +++ b/docs/developer/gadi_singularity/README.md @@ -4,6 +4,57 @@ This directory contains two Containerfiles to build the Underworld3 (UW3) Singul Both use Rocky Linux 8.10 to match Gadi's OS for ABI compatibility. +## Automated builds + +Both images are built by GitHub Actions; the manual steps below are only needed for +local iteration or if you are building outside the `underworldcode` org. + +| Workflow | Builds | Publishes | Runs when | +|---|---|---|---| +| `.github/workflows/gadi-uw3-image.yml` | `underworld3.rhel` | `ghcr.io//underworld3-gadi:` (`:latest` on release) | push to `main`/`development` touching `src/**`, `pixi.toml`, `pyproject.toml`, `setup.py` or this directory; published releases; manual dispatch | +| `.github/workflows/gadi-petsc-image.yml` | `petsc.rhel` | `ghcr.io//petsc-gadi:-ompi` (+ `:latest`) | push touching `petsc.rhel` or `petsc-custom/patches/**`; manual dispatch | + +PETSc is a ~25 min from-source build, so it is kept as a separate, rarely-triggered +workflow and the published image is reused as the base for every Underworld3 build. +To rebuild it explicitly: + +```bash +gh workflow run gadi-petsc-image.yml -f petsc_version=3.25.0 -f make_np=4 +``` + +To build the Underworld3 image from a specific ref: + +```bash +gh workflow run gadi-uw3-image.yml -f uw3_branch=v3.1.0 -f image_tag=v3.1.0 +``` + +Each build is followed by a smoke test: `import underworld3`, an import check for the +optional gmsh/vtk-osmesa stack that the Containerfile installs behind `|| echo skip`, +and a 2-rank MPI check. + +### Retention + +Only the **three most recent untagged** `underworld3-gadi` versions are kept (two for +`petsc-gadi`); older ones are pruned automatically after the smoke test passes. Tagged +images — `:latest`, `:main`, `:development`, and every `v*` release — are never pruned. + +### Package visibility + +A package published by these workflows inherits the repository's visibility, so on a +public repo both images are publicly pullable and Gadi compute nodes — which have no +GitHub credentials — can `singularity pull` them directly. A package created by hand +(`podman push` from a laptop) is **not** linked to the repository and does default to +private; set it public under its package settings, or authenticate the pull: + +```bash +export SINGULARITY_DOCKER_USERNAME= +export SINGULARITY_DOCKER_PASSWORD= +``` + +Note the workflows build single-platform `linux/amd64` images with buildx attestations +disabled (`provenance: false`, `sbom: false`). Attestations add an `unknown/unknown` +entry to the manifest index that some Singularity/Apptainer versions refuse to pull. + ## Build Order Build commands must be run from the top-level `underworld3/` directory (the build context). @@ -15,14 +66,14 @@ Builds targeting Gadi must use `--platform linux/amd64`. podman build . \ --platform linux/amd64 \ --format docker \ - -t ghcr.io//petsc:3.25.0-ompi \ + -t ghcr.io//petsc-gadi:3.25.0-ompi \ -f ./docs/developer/gadi_singularity/petsc.rhel ``` ### 2. Push PETSc image to registry ```bash -podman push ghcr.io//petsc:3.25.0-ompi +podman push ghcr.io//petsc-gadi:3.25.0-ompi ``` ### 3. Build Underworld3 @@ -31,7 +82,7 @@ podman push ghcr.io//petsc:3.25.0-ompi podman build . \ --platform linux/amd64 \ --format docker \ - --build-arg PETSC_IMAGE=ghcr.io//petsc:3.25.0-ompi \ + --build-arg PETSC_IMAGE=ghcr.io//petsc-gadi:3.25.0-ompi \ --build-arg UW3_BRANCH=development \ -t ghcr.io//underworld3-gadi:latest \ -f ./docs/developer/gadi_singularity/underworld3.rhel diff --git a/docs/developer/gadi_singularity/petsc.rhel b/docs/developer/gadi_singularity/petsc.rhel index e612826dc..a13ead632 100644 --- a/docs/developer/gadi_singularity/petsc.rhel +++ b/docs/developer/gadi_singularity/petsc.rhel @@ -36,8 +36,10 @@ ARG BASE_IMAGE="quay.io/rockylinux/rockylinux:8.10" FROM ${BASE_IMAGE} as runtime LABEL maintainer="https://github.com/underworldcode/" -# need to repeat ARGS after every FROM -ARG PYTHON_VERSION +# Repeat the DEFAULT, not just the name: bare `ARG PYTHON_VERSION` relies on +# inheriting the pre-FROM default, which some buildah/podman versions don't — +# PYVER expands empty and yum then looks for a package called "python-pip". +ARG PYTHON_VERSION="3.12" #### Containerfile ENV vars - for all image stages ENV LANG=C.UTF-8 @@ -85,8 +87,13 @@ RUN python${PYVER} -m pip install wheel \ # 2. Define the builder layer FROM runtime as builder -ARG PETSC_VERSION -ARG PYTHON_VERSION +# defaults repeated deliberately — see the note in the runtime stage +ARG PETSC_VERSION="3.25.0" +ARG PYTHON_VERSION="3.12" +# Parallelism for PETSc's own build. 2 is safe inside a memory-constrained podman +# machine (compiling PETSc.c is memory-heavy and OOMs at higher values); CI on a +# native runner passes 4. +ARG PETSC_MAKE_NP="2" RUN yum install -y \ ca-certificates \ @@ -108,7 +115,10 @@ RUN yum install -y \ && rm -rf /var/cache/yum # NOTE flex and bison are needed to build -RUN python${PYVER} -m pip install "cython>=3.1" \ +# Upper bound is load-bearing: petsc4py 3.25.0 declares only "cython >= 3" but +# PC.pyx:1256 fails to cythonize under 3.3.0 ("Invalid index type 'int'"). +# Bisected against the 3.25.0 source: 3.2.9 is the last version that builds. +RUN python${PYVER} -m pip install "cython>=3.1,<3.3" \ "setuptools>=75" \ "meson" \ "meson-python" \ @@ -165,7 +175,7 @@ RUN python${PYVER} ./configure \ --download-zlib=1 \ --download-ctetgen=1 \ --download-triangle=1 \ - --with-make-np=2 + --with-make-np=${PETSC_MAKE_NP} RUN make PETSC_DIR=`pwd` PETSC_ARCH=arch-linux-c-opt all RUN make PETSC_DIR=`pwd` PETSC_ARCH=arch-linux-c-opt install \ || (echo "=== petsc4py build log ===" && \ diff --git a/docs/developer/gadi_singularity/underworld3.rhel b/docs/developer/gadi_singularity/underworld3.rhel index 21a712f30..e865576e4 100644 --- a/docs/developer/gadi_singularity/underworld3.rhel +++ b/docs/developer/gadi_singularity/underworld3.rhel @@ -19,7 +19,7 @@ # # To build use podman from the top level underworld didrectory. i.e. # $ podman build . \ - --platform linux/amd64 \ +# --platform linux/amd64 \ # --format docker \ # --build-arg UW3_BRANCH=xxx \ # -t new_image_name \ @@ -31,8 +31,11 @@ # https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact ARG PYTHON_VERSION="3.12" ARG BASE_IMAGE="quay.io/rockylinux/rockylinux:8.10" -ARG PETSC_IMAGE="ghcr.io/jcgraciosa/petsc:3.25.0-ompi" +ARG PETSC_IMAGE="ghcr.io/underworldcode/petsc-gadi:3.25.0-ompi" ARG UW3_BRANCH="development" +# Overridden by CI to the repository being built, so a fork builds its own +# branches rather than looking for them upstream. +ARG UW3_REPO="https://github.com/underworldcode/underworld3.git" # 'petsc-image' will be used later on in builder stage COPY command FROM ${PETSC_IMAGE} as petsc-image @@ -43,8 +46,10 @@ FROM ${PETSC_IMAGE} as petsc-image FROM ${BASE_IMAGE} as runtime LABEL maintainer="https://github.com/underworldcode/" -# need to repeat ARGS after every FROM -ARG PYTHON_VERSION +# Repeat the DEFAULT, not just the name: bare `ARG PYTHON_VERSION` relies on +# inheriting the pre-FROM default, which some buildah/podman versions don't — +# PYVER expands empty and yum then looks for a package called "python-pip". +ARG PYTHON_VERSION="3.12" #### Containerfile ENV vars - for all image stages ENV LANG=C.UTF-8 @@ -102,8 +107,11 @@ ENV PYTHONPATH=$PYTHONPATH:$PETSC_DIR/lib # Stage 2: 'builder' #################### FROM petsc-image as builder -ARG PYTHON_VERSION -ARG UW3_BRANCH +# defaults repeated deliberately — see the note in the runtime stage +ARG PYTHON_VERSION="3.12" +ARG UW3_BRANCH="development" +ARG UW3_REPO="https://github.com/underworldcode/underworld3.git" +ARG SETUPTOOLS_SCM_PRETEND_VERSION="" # root to install with yum USER root @@ -123,6 +131,7 @@ RUN yum install -y\ # install python build time and runtime requirements here RUN python${PYVER} -m pip install --no-cache-dir \ "setuptools>=75" \ + "setuptools-scm>=8" \ "cython>=3.1" \ "scipy>=1.15" \ "numpy<2" \ @@ -136,9 +145,10 @@ RUN python${PYVER} -m pip install --no-cache-dir \ psutil \ typing_extensions \ xxhash \ + requests \ trimesh \ ipython \ - jupyterlab \ + "jupyterlab>=4.3,<5" \ "ipywidgets<9.0.0" \ jupyter-server-proxy \ "trame>=2.5.2" \ @@ -168,9 +178,13 @@ RUN pip install --no-cache-dir --extra-index-url https://wheels.vtk.org vtk-osme # Clone and install uw3 RUN git clone --depth 1 --branch ${UW3_BRANCH} \ - https://github.com/underworldcode/underworld3.git /tmp/underworld3 + ${UW3_REPO} /tmp/underworld3 WORKDIR /tmp/underworld3 -RUN pip install --no-build-isolation --no-cache-dir . +# The clone above is shallow and forks carry no tags, so setuptools-scm has +# nothing to derive from and would stamp the build 0.0.0. CI passes the real +# version; unset when empty so a local build keeps setuptools-scm's own logic. +RUN [ -n "$SETUPTOOLS_SCM_PRETEND_VERSION" ] || unset SETUPTOOLS_SCM_PRETEND_VERSION; \ + pip install --no-build-isolation --no-cache-dir . # record 'builder' stage packages used RUN python${PYVER} -m pip freeze >/opt/requirements.txt \