From d476c7764b24806ec9109eda80a2db5ac595831b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 10:51:51 +0000 Subject: [PATCH] docker: address review round 3 (notebook -r filter, studio zoo ref, pinned notebooks commit) - unsloth_pip_shim.py: filter protected packages out of a notebook `pip install -r requirements.txt`. The -r value was passed to the real pip unchanged, so torch / transformers / vLLM / nvidia pins inside the file could overwrite the baked cu128 stack or push transformers into the base venv. _filter_requirements_file() applies the same _KEEP / transformers-sidecar rules per line, writes the survivors to a temp file, keeps comments, option lines, nested includes and urls verbatim, and records a pinned transformers version for the sidecar. - install.sh + Dockerfile.studio + docker-publish.yml: forward the resolved unsloth-zoo ref into the Studio build. install.sh --local overlaid unsloth-zoo from git main regardless of the operator-requested or base-image ref, so the full image could run a different zoo than the base. install.sh now honors UNSLOTH_ZOO_REF across all four --local overlays, Dockerfile.studio passes UNSLOTH_STUDIO_ZOO_REF through to it, and the workflow resolves one zoo ref in the prepare job and shares it with both the base and Studio builds. - Dockerfile + docker-publish.yml: pin unslothai/notebooks to one resolved commit. Each arch leg cloned HEAD independently, so the same tag could seed different baked templates and .unsloth_template_commit depending on the pulled platform. The prepare job freezes notebooks to one sha (like the llama.cpp prebuilt tag) and the Dockerfile fetches that single ref at depth 1. --- .github/workflows/docker-publish.yml | 95 ++++++++++++++++++++-------- docker/Dockerfile | 14 +++- docker/Dockerfile.studio | 6 ++ docker/unsloth_pip_shim.py | 69 ++++++++++++++++++-- install.sh | 34 ++++++---- 5 files changed, 173 insertions(+), 45 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d68f7c1889..e86fec2790 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -48,6 +48,10 @@ on: description: 'unslothai/llama.cpp prebuilt release tag to bake (blank = newest)' required: false default: '' + notebooks_ref: + description: 'unslothai/notebooks git ref to bake (resolved to one commit)' + required: false + default: 'main' env: REGISTRY: docker.io @@ -85,6 +89,12 @@ jobs: contents: read outputs: llama_tag: ${{ steps.llama.outputs.tag }} + # One zoo ref + one notebooks commit, resolved here so BOTH arch legs of + # the base build (and the Studio build) bake the identical bits. Resolving + # them per-leg would let upstream advance between the amd64 and arm64 + # builds, putting different content under one published tag. + zoo_ref: ${{ steps.zoo_ref.outputs.ref }} + notebooks_commit: ${{ steps.notebooks.outputs.commit }} steps: - name: Resolve llama.cpp prebuilt tag id: llama @@ -100,6 +110,46 @@ jobs: echo "tag=${TAG:-latest}" >> "$GITHUB_OUTPUT" echo "llama.cpp prebuilt tag: ${TAG:-latest}" + # Mirror the unsloth tag into the zoo ONLY when that tag actually exists + # there. unsloth's v* tags are Studio releases the zoo never cuts (the zoo + # repo currently has no tags at all), so blindly mirroring github.ref_name + # made every tag publish fail inside the Dockerfile's zoo install. Resolved + # once here and forwarded to the base build AND the Studio build, so the + # full image's Studio venv runs the same zoo as the base image. + - name: Resolve unsloth-zoo ref + id: zoo_ref + run: | + REF="${{ github.event.inputs.unsloth_zoo_ref }}" + if [ -z "$REF" ] && [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then + if git ls-remote --exit-code --tags https://github.com/unslothai/unsloth-zoo \ + "refs/tags/${{ github.ref_name }}" >/dev/null 2>&1; then + REF="${{ github.ref_name }}" + fi + fi + echo "ref=${REF:-main}" >> "$GITHUB_OUTPUT" + echo "unsloth-zoo ref: ${REF:-main}" + + # Freeze unslothai/notebooks to ONE concrete commit so both arch legs (and + # release reruns) bake the identical baked-notebook templates and + # .unsloth_template_commit, even if upstream advances mid-build. A 40-char + # sha input is already frozen; a branch/tag (default main) is resolved to + # its current sha via ls-remote, falling back to the bare ref on a lookup + # miss so the Dockerfile can still fetch it by name. + - name: Resolve unsloth/notebooks commit + id: notebooks + env: + INPUT_REF: ${{ github.event.inputs.notebooks_ref }} + run: | + REF="${INPUT_REF:-main}" + if printf '%s' "$REF" | grep -Eq '^[0-9a-f]{40}$'; then + SHA="$REF" + else + SHA="$(git ls-remote https://github.com/unslothai/notebooks "$REF" | awk 'NR==1{print $1}')" + [ -n "$SHA" ] || SHA="$REF" + fi + echo "commit=${SHA}" >> "$GITHUB_OUTPUT" + echo "notebooks commit: ${SHA}" + # --------------------------------------------------------------------------- # Per-arch build. The matrix fans out two parallel jobs on the matching # native runner. Each pushes a single-arch image *by digest* (no human- @@ -164,24 +214,6 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # Mirror the unsloth tag into the zoo ONLY when that tag actually - # exists there. unsloth's v* tags are Studio releases the zoo never - # cuts (the zoo repo currently has no tags at all), so blindly - # mirroring github.ref_name made every tag publish fail inside the - # Dockerfile's zoo install. - - name: Resolve unsloth-zoo ref - id: zoo_ref - run: | - REF="${{ github.event.inputs.unsloth_zoo_ref }}" - if [ -z "$REF" ] && [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then - if git ls-remote --exit-code --tags https://github.com/unslothai/unsloth-zoo \ - "refs/tags/${{ github.ref_name }}" >/dev/null 2>&1; then - REF="${{ github.ref_name }}" - fi - fi - echo "ref=${REF:-main}" >> "$GITHUB_OUTPUT" - echo "unsloth-zoo ref: ${REF:-main}" - - name: Build and push (per-arch by digest) id: build uses: docker/build-push-action@v6 @@ -202,18 +234,21 @@ jobs: # pushes bake the tag's source ref (e.g. v1.2.3) so the published # image actually contains that release; branch + scheduled runs bake # the triggering commit SHA; any other event falls back to main. - # UNSLOTH_ZOO_REF (from the resolve step above): explicit dispatch - # input, else the pushed tag IF the zoo repo has it, else main -- a - # branch SHA does not exist in the zoo repo. - # LLAMA_PREBUILT_TAG (from the prepare job): one concrete tag shared - # by both arch legs so the published manifest is reproducible. + # UNSLOTH_ZOO_REF (from the prepare job): explicit dispatch input, + # else the pushed tag IF the zoo repo has it, else main -- a branch + # SHA does not exist in the zoo repo. Resolved once in `prepare` and + # shared with the Studio build so both venvs run the same zoo. + # LLAMA_PREBUILT_TAG / UNSLOTH_NOTEBOOKS_REF (from the prepare job): + # one concrete tag / commit shared by both arch legs so the + # published manifest is byte-reproducible across platforms. build-args: | CUDA_VERSION=12.8.1 UBUNTU_VERSION=24.04 PYTHON_VERSION=3.12 UNSLOTH_REF=${{ github.event.inputs.unsloth_ref || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || github.sha || 'main' }} - UNSLOTH_ZOO_REF=${{ steps.zoo_ref.outputs.ref }} + UNSLOTH_ZOO_REF=${{ needs.prepare.outputs.zoo_ref }} LLAMA_PREBUILT_TAG=${{ needs.prepare.outputs.llama_tag }} + UNSLOTH_NOTEBOOKS_REF=${{ needs.prepare.outputs.notebooks_commit }} # Stash the per-arch digest as an artifact for the merge job to pick up. # Filenames need to be unique across the matrix; `platform` contains a @@ -319,7 +354,9 @@ jobs: # that is the long pole, hence the larger timeout. # --------------------------------------------------------------------------- build-studio: - needs: merge + # `merge` for the freshly-published base manifest digest; `prepare` for the + # one resolved zoo ref (job outputs only flow through direct `needs`). + needs: [prepare, merge] strategy: fail-fast: false matrix: @@ -382,11 +419,15 @@ jobs: cache-to: type=gha,scope=studio-${{ matrix.platform }},mode=min outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true # UNSLOTH_STUDIO_REF mirrors the base job's UNSLOTH_REF resolution so the - # Studio tree matches the unsloth baked into the base venv. (Prose stays - # out of build-args -- forwarded lines must be KEY=VALUE only.) + # Studio tree matches the unsloth baked into the base venv. + # UNSLOTH_STUDIO_ZOO_REF is the SAME resolved zoo ref the base build + # baked, so install.sh --local overlays the Studio venv with that zoo + # instead of always tracking main. (Prose stays out of build-args -- + # forwarded lines must be KEY=VALUE only.) build-args: | BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.merge.outputs.digest }} UNSLOTH_STUDIO_REF=${{ github.event.inputs.unsloth_ref || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || github.sha || 'main' }} + UNSLOTH_STUDIO_ZOO_REF=${{ needs.prepare.outputs.zoo_ref }} - name: Export digest run: | diff --git a/docker/Dockerfile b/docker/Dockerfile index 8b937ab827..167b27eea7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -694,8 +694,20 @@ ENV PATH=/opt/unsloth-nb/bin:${PATH} # / announcements / footer moved upstream (the tutorial body is unchanged) -- # see unsloth_sync_notebooks.sh + unsloth_nb_content_sig.py. Inherited as-is by # the studio image (FROM base). +# +# UNSLOTH_NOTEBOOKS_REF pins ONE commit/branch/tag so a multi-arch publish bakes +# identical templates into both arch legs even if unslothai/notebooks advances +# mid-build. The publish workflow resolves the live HEAD sha once (like +# LLAMA_PREBUILT_TAG) and passes it here; the default "main" keeps a plain +# `docker build` tracking the tip. We fetch the single resolved ref (a full +# 40-char sha fetches by object; a branch/tag fetches by name) at depth 1, so +# the bake stays a shallow one-commit pull. +ARG UNSLOTH_NOTEBOOKS_REF=main RUN set -eux \ - && git clone --depth 1 https://github.com/unslothai/notebooks /opt/unsloth-notebooks \ + && git init -q /opt/unsloth-notebooks \ + && git -C /opt/unsloth-notebooks remote add origin https://github.com/unslothai/notebooks \ + && git -C /opt/unsloth-notebooks fetch -q --depth 1 origin "${UNSLOTH_NOTEBOOKS_REF}" \ + && git -C /opt/unsloth-notebooks checkout -q FETCH_HEAD \ && git -C /opt/unsloth-notebooks rev-parse HEAD > /opt/unsloth-notebooks/.unsloth_template_commit \ && rm -rf /opt/unsloth-notebooks/.git \ && du -sh /opt/unsloth-notebooks diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index 60f92a02db..b752292336 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -34,6 +34,11 @@ FROM ${BASE_IMAGE} # that pins BASE_IMAGE to a digest should pin this too (same UNSLOTH_REF as # the base) so the published image is reproducible against a known ref. ARG UNSLOTH_STUDIO_REF=main +# unsloth-zoo ref overlaid into the Studio venv by install.sh --local. The +# publish workflow resolves ONE zoo ref and passes it to both the base and +# Studio builds, so the Studio backend runs the same zoo as the base image and +# the operator-requested ref instead of always tracking main. +ARG UNSLOTH_STUDIO_ZOO_REF=main ARG TARGETARCH # Services run as root in this revision (the base image is root-only by @@ -106,6 +111,7 @@ RUN set -eux \ && git checkout -q FETCH_HEAD \ && UNSLOTH_STUDIO_HOME="${UNSLOTH_STUDIO_HOME}" \ UNSLOTH_TORCH_INDEX_FAMILY="${TORCH_FAMILY}" \ + UNSLOTH_ZOO_REF="${UNSLOTH_STUDIO_ZOO_REF}" \ UNSLOTH_PYTHON=3.12 \ bash install.sh --local \ # Fail loud if the Studio venv torch missed the pinned CUDA family (an diff --git a/docker/unsloth_pip_shim.py b/docker/unsloth_pip_shim.py index 42c774f401..8810beb7c3 100644 --- a/docker/unsloth_pip_shim.py +++ b/docker/unsloth_pip_shim.py @@ -21,7 +21,7 @@ are not intercepted -- the driven `unsloth-run` handles those by parsing the notebook directly. """ -import os, re, sys, subprocess +import os, re, sys, subprocess, tempfile REAL = {"pip": "/opt/unsloth-venv/bin/pip", "uv": "/opt/unsloth-venv/bin/uv"} MARKER = os.environ.get("UNSLOTH_NB_TF_MARKER", "/tmp/unsloth_nb/requested_transformers") @@ -95,6 +95,56 @@ def _version_pin(token): return m.group(1) if m else None +def _filter_requirements_file(path): + """Strip baked/protected packages out of a `-r` requirements file. + + Returns (path_to_use, recorded_transformers_version, dropped_specs). The same + _KEEP / transformers rules the inline args get are applied to each requirement + line, so a notebook `pip install -r reqs.txt` cannot overwrite the cu128 torch + / vLLM / transformers stack with versions pinned inside the file. When nothing + is protected, or the file cannot be read/written, the original path is returned + unchanged. Comments, blank lines, option lines and nested `-r`/`-c` includes are + kept verbatim (nested includes are passed through, i.e. filtered one level). + """ + try: + with open(path, encoding = "utf-8") as f: + lines = f.readlines() + except OSError: + return path, None, [] # remote URL / unreadable -> let the real tool handle it + out, dropped, recorded, changed = [], [], None, False + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith(("#", "-")): + out.append(line) # comment / blank / option / nested include -> keep + continue + spec = stripped.split(" #", 1)[0].strip() # drop any inline comment + name = _canon(spec) + if name is None: + out.append(line) # url / path / vcs / unparseable -> keep + continue + if name == "transformers": + v = _version_pin(spec) + if v and not recorded: + recorded = v + dropped.append(spec) + changed = True + continue + if name in _KEEP or name.startswith(_KEEP_PREFIX): + dropped.append(spec) + changed = True + continue + out.append(line) + if not changed: + return path, None, [] + try: + fd, tmp = tempfile.mkstemp(prefix = "unsloth-nb-req-", suffix = ".txt") + with os.fdopen(fd, "w", encoding = "utf-8") as f: + f.writelines(out) + except OSError: + return path, None, [] # can't write temp -> pass the file through unchanged + return tmp, recorded, dropped + + def main(): tool = "uv" if os.path.basename(sys.argv[0]).startswith("uv") else "pip" argv = sys.argv[1:] @@ -125,12 +175,21 @@ def main(): prev_flag = None for tok in tail: if skip_next: - keep_args.append(tok) - # The value of -r/--requirement pulls real requirements (a target); - # the value of an index-url / find-links / constraint / etc. flag is - # an option, not something to install. + # The value of -r/--requirement pulls real requirements (a target); the + # value of an index-url / find-links / constraint / etc. flag is an + # option, not something to install. if prev_flag in _REQ_FILE_FLAGS: + # Filter baked/protected packages out of the requirements file so a + # notebook `pip install -r reqs.txt` cannot clobber the cu128 stack + # or push transformers into the base venv. + _req_path, _req_rec, _req_drp = _filter_requirements_file(tok) + keep_args.append(_req_path) has_target = True + if _req_rec and not recorded: + recorded = _req_rec + dropped.extend(_req_drp) + else: + keep_args.append(tok) skip_next = False prev_flag = None continue diff --git a/install.sh b/install.sh index 318b03e054..fc5bc404ba 100755 --- a/install.sh +++ b/install.sh @@ -1866,6 +1866,16 @@ fi # ── Resolve repo root (for --local installs) ── _REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)" +# ── unsloth-zoo overlay ref (for --local installs) ── +# --local installs overlay unsloth-zoo straight from git so the Studio venv +# tracks the same zoo as the editable unsloth checkout. Honor UNSLOTH_ZOO_REF +# (the Docker publish workflow resolves one ref and forwards it to BOTH the base +# and Studio builds) so the published image runs the operator-requested zoo, not +# whatever main happens to be at build time. Unset -> main, byte-identical to the +# previous bare git URL (pip treats no @ref as the repo's default branch). +_ZOO_REF="${UNSLOTH_ZOO_REF:-main}" +_ZOO_GIT_SPEC="unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo@${_ZOO_REF}" + # ── Helper: find no-torch-runtime.txt (local repo or site-packages) ── _find_no_torch_runtime() { # Check local repo first (for --local installs) @@ -2666,10 +2676,10 @@ if [ "$_MIGRATED" = true ]; then if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps - substep "overlaying unsloth-zoo from git main..." - run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + substep "overlaying unsloth-zoo from git ${_ZOO_REF}..." + run_install_cmd_retry "overlay unsloth-zoo (git ${_ZOO_REF})" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ - "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" + "$_ZOO_GIT_SPEC" fi # AMD ROCm: install bitsandbytes even in migrated environments so # existing ROCm installs gain the AMD bitsandbytes build without a @@ -2876,20 +2886,20 @@ elif [ -n "$TORCH_INDEX_URL" ]; then if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps - substep "overlaying unsloth-zoo from git main..." - run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + substep "overlaying unsloth-zoo from git ${_ZOO_REF}..." + run_install_cmd_retry "overlay unsloth-zoo (git ${_ZOO_REF})" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ - "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" + "$_ZOO_GIT_SPEC" fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps - substep "overlaying unsloth-zoo from git main..." - run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + substep "overlaying unsloth-zoo from git ${_ZOO_REF}..." + run_install_cmd_retry "overlay unsloth-zoo (git ${_ZOO_REF})" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ - "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" + "$_ZOO_GIT_SPEC" else run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth -- "$PACKAGE_NAME" @@ -2918,10 +2928,10 @@ else run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps - substep "overlaying unsloth-zoo from git main..." - run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + substep "overlaying unsloth-zoo from git ${_ZOO_REF}..." + run_install_cmd_retry "overlay unsloth-zoo (git ${_ZOO_REF})" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ - "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" + "$_ZOO_GIT_SPEC" else run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME" fi