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.
This commit is contained in:
Daniel Han 2026-06-26 10:51:51 +00:00
commit d476c7764b
5 changed files with 173 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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

View file

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