From 8f693c620773258d1a9f8a24ab8ac02798a31bc8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 08:19:05 +0000 Subject: [PATCH] docker: fix notebook pip-shim drops, first-boot overwrite, base :latest tag, arm64 decord - pip shim: count editable/local/url/vcs targets (-e ., ., git+https, wheel URLs) as install targets, not just canonical package names, so they are no longer silently skipped inside notebooks - notebook sync: never overwrite a pre-existing user notebook on first boot (match the refresh path's ownership rule); skip .unsloth_sync_state.tmp when recording state so it is not tracked as a managed file - docker-publish: set flavor latest=false on the base image metadata so a v* tag push cannot publish :latest from the base image (the Studio image owns it) - notebook deps: pin to tested versions and install decord on its own, hard on amd64 and fail-soft on arm64 (no aarch64 wheel) so the arm64 base build works --- .github/workflows/docker-publish.yml | 7 +++++++ docker/Dockerfile | 25 +++++++++++++++++++++---- docker/unsloth_pip_shim.py | 9 ++++++--- docker/unsloth_sync_notebooks.sh | 12 +++++++++++- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 94480c367e..0226343192 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -232,6 +232,10 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # The base image must NEVER claim :latest. metadata-action defaults to + # flavor latest=auto, which would tag :latest on a v* (semver) tag push + # and collide with the Studio image that legitimately owns :latest. + flavor: latest=false tags: | # The lean training image publishes under the base- prefix; the # full Studio image (build-studio/merge-studio below) owns @@ -437,6 +441,9 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # Keep the base image off :latest here too (this recomputes the same + # tag list the merge step pushed, so the smoke test pulls the right ref). + flavor: latest=false tags: | type=raw,value=base,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} type=ref,event=tag,prefix=base- diff --git a/docker/Dockerfile b/docker/Dockerfile index 70c910ab1e..5ae04ef038 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -285,18 +285,35 @@ RUN set -eux \ # omegaconf TTS families + both NeMo-Gym RL notebooks' config objects # einx TTS codec tensor-rearrange (Llasa / Oute / Spark TTS) # librosa Whisper audio feature extraction (pairs with soundfile + torchcodec) -# decord ERNIE-VL vision notebook video decode # ftfy Oute TTS text normalisation +# decord (ERNIE-VL video decode) is installed separately below: it ships no +# aarch64 wheel, so a hard install here would break the arm64 build. # librosa pulls numba/soxr/audioread; numba is already pinned >=0.65 (numpy 2.4 # compatible) by the vLLM pass, so the resolve must NOT move torch/numpy/numba -- # the assertion below fails the build loudly if it did. +# Pinned (==) to the resolved, tested versions for reproducible rebuilds -- the +# same convention as the cu128 core (torch/torchvision/torchaudio). Bump these +# deliberately, not silently on the next build. Transitive deps of these are +# captured by the full venv lockfile (docker/freeze.sh -> requirements.lock.txt). RUN ${VENV}/bin/uv pip install \ --python ${VENV}/bin/python \ - jupyterlab notebook ipywidgets matplotlib \ - soundfile evaluate jiwer tensorboard langid easydict protobuf \ - omegaconf einx librosa decord ftfy \ + "jupyterlab==4.6.0" "notebook==7.6.0" "ipywidgets==8.1.8" "matplotlib==3.11.0" \ + "soundfile==0.14.0" "evaluate==0.4.6" "jiwer==4.0.0" "tensorboard==2.20.0" \ + "langid==1.1.6" "easydict==1.13" "protobuf==6.33.6" \ + "omegaconf==2.3.1" "einx==0.4.3" "librosa==0.11.0" "ftfy==6.3.1" \ && ${VENV}/bin/python -c "import torch, numpy, numba; from packaging.version import Version; assert torch.__version__.startswith('2.10.0'), torch.__version__; assert Version(numpy.__version__) >= Version('2.3'), numpy.__version__; assert Version(numba.__version__) >= Version('0.65'), numba.__version__; print('notebook-deps pins OK:', torch.__version__, numpy.__version__, numba.__version__)" +# decord (ERNIE-VL video decode) publishes wheels only for x86_64 / win_amd64. +# Install it on its own: HARD on amd64 (a missing/incompatible wheel is a real +# regression there and must fail the build), fail-soft on arm64/other (no wheel +# exists, so drop the ERNIE-VL video path rather than break the image build). +RUN if [ "${TARGETARCH:-amd64}" = "amd64" ]; then \ + ${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0"; \ + else \ + ${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0" \ + || echo ">> decord skipped (no matching wheel for ${TARGETARCH:-}); ERNIE-VL video decode unavailable"; \ + fi + # Audio decode out of the box: the TTS/STT notebooks feed datasets' Audio # features, which decode through torchcodec. Three traps, all defended: # * version pairing: torchcodec 0.10 pairs with torch 2.10 (newer builds diff --git a/docker/unsloth_pip_shim.py b/docker/unsloth_pip_shim.py index 666b5c0cd8..635de00fe7 100644 --- a/docker/unsloth_pip_shim.py +++ b/docker/unsloth_pip_shim.py @@ -155,9 +155,12 @@ def main(): if dropped: print("[unsloth-nb] kept baked versions, skipped: " + " ".join(dropped)) - # Anything left to actually install? (a requirement, not just flags) - real_reqs = [t for t in keep_args if _canon(t)] - if not real_reqs: + # Anything left to actually install? Count any non-flag token as a target, + # not just tokens with a canonical pkg name: editable / local / url / vcs + # installs (`-e .`, `.`, `git+https://...`, a wheel URL) carry no canonical + # name but must still run, and a `-r`/`-c` file pulls in real requirements. + has_install_target = any(not t.startswith("-") for t in keep_args) + if not has_install_target: print("[unsloth-nb] nothing to install after keeping the baked stack; ok.") return cmd = [REAL[tool]] + head + keep_args diff --git a/docker/unsloth_sync_notebooks.sh b/docker/unsloth_sync_notebooks.sh index 7e251574aa..73db179e34 100644 --- a/docker/unsloth_sync_notebooks.sh +++ b/docker/unsloth_sync_notebooks.sh @@ -69,7 +69,7 @@ record_state() { ( cd "$DEST" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do rel="${rel#./}" case "$rel" in - .unsloth_sync_state|.unsloth_sync_commit) continue ;; + .unsloth_sync_state|.unsloth_sync_state.tmp|.unsloth_sync_commit) continue ;; esac printf '%s %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp" done @@ -82,6 +82,16 @@ if [ ! -f "$STATE" ]; then rel="${rel#./}" case "$rel" in .unsloth_template_commit) continue ;; esac mkdir -p "$DEST/$(dirname "$rel")" 2>/dev/null || true + # A pre-existing file at this path (bind-mounted or hand-created before + # the first boot) is user data: never clobber it. Only lay down the baked + # template when the path is empty or already byte-identical to it. The + # refresh path below has the same ownership rule; this keeps first boot + # symmetric so a mounted notebook survives the very first start too. + if [ -e "$DEST/$rel" ] \ + && [ "$(hash_of "$DEST/$rel")" != "$(hash_of "$TEMPLATE/$rel")" ]; then + echo "[unsloth-nb] kept existing user file: $DEST/$rel" + continue + fi cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null || true done record_state