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
This commit is contained in:
Daniel Han 2026-06-26 08:19:05 +00:00
commit 8f693c6207
4 changed files with 45 additions and 8 deletions

View file

@ -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-

View file

@ -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

View file

@ -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

View file

@ -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