Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions .github/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,27 @@
# Two separate Python environments by design:
# - the kernel env is the system Python (/usr/local): pins go in with
# `uv pip install --system`, matching both the Colab bootstrap cell and
# run_notebook.py, so the CI harness runs unmodified inside the image;
# run_notebook.py, so the CI harness runs unmodified inside the image
# (CI runs it with --user root so its transient installs can write there);
# - JupyterLab lives in an isolated `uv tool` env so its own dependency
# tree can never upgrade or downgrade anything in the pinned kernel env.
#
# The server runs as the non-root user jovyan (uid 1000).

ARG BASE_IMAGE=python:3.12-slim@sha256:2c941e860699f878900b0edc2403613c234d4b32eda3cc9fa7036991a2a63c4a
FROM ${BASE_IMAGE}

RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 --shell /bin/bash jovyan

COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /uvx /usr/local/bin/

# The tool env lives under /opt (not /root) so it is readable by jovyan.
ARG JUPYTERLAB_VERSION=4.6.3
ENV UV_TOOL_BIN_DIR=/opt/uv-bin
ENV UV_TOOL_DIR=/opt/uv-tools \
UV_TOOL_BIN_DIR=/opt/uv-bin
RUN uv tool install "jupyterlab==${JUPYTERLAB_VERSION}"
# JUPYTER_PATH makes the pinned kernelspec under /usr/local win over the
# kernelspec that ships inside the JupyterLab tool env.
Expand All @@ -29,12 +35,33 @@ ENV PATH="/opt/uv-bin:${PATH}" \

# ipykernel and nbformat resolve jointly with the pins in one invocation so
# any conflict fails the build instead of silently changing a pinned version.
# gcc/libc6-dev cover pins with no wheel for the target arch (e.g. old psutil
# on arm64) and are purged in the same layer to keep the image slim.
COPY requirements.txt /tmp/requirements.txt
RUN uv pip install --system --no-cache -r /tmp/requirements.txt ipykernel nbformat \
&& python -m ipykernel install --name python3 --display-name "Python 3 (pinned)"
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libc6-dev \
&& uv pip install --system --no-cache -r /tmp/requirements.txt ipykernel nbformat \
&& python -m ipykernel install --name python3 --display-name "Python 3 (pinned)" \
&& apt-get purge -y gcc libc6-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /work
COPY work/ /work/
COPY --chown=jovyan:jovyan work/ /work/

USER jovyan
ENV HOME=/home/jovyan

# Matplotlib builds its font cache on first import; do it at build time (as
# jovyan, so the cache lands in the home directory the server runs from) so
# the first cell a user runs doesn't stall on it. Under Rosetta emulation the
# subprocess spawn involved can even deadlock, so it must not happen at runtime.
RUN python - <<'PYEOF'
try:
import matplotlib.pyplot # noqa: F401
except ImportError:
pass
PYEOF

ARG DEFAULT_NOTEBOOK
ARG BUILD_HASH
Expand All @@ -47,5 +74,5 @@ LABEL org.opencontainers.image.source="https://github.com/dandi/example-notebook
org.dandiarchive.build-hash="${BUILD_HASH}"

EXPOSE 8888
CMD jupyter-lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root \
CMD jupyter-lab --ip=0.0.0.0 --port=8888 --no-browser \
--ServerApp.default_url="/lab/tree/${DEFAULT_NOTEBOOK}"
42 changes: 32 additions & 10 deletions .github/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@ remains runnable long after the hosted environments have moved on.
## Running a Notebook Image

```
docker run --rm -p 8888:8888 ghcr.io/dandi/example-notebooks/001550-paganlab:latest
docker run --rm -p 127.0.0.1:8888:8888 ghcr.io/dandi/example-notebooks/001550-paganlab:latest
```

Then open the `http://127.0.0.1:8888/lab?token=...` URL printed in the
terminal. JupyterLab opens on the notebook with its dependencies already
terminal. If you already have a Jupyter server running on port 8888 (common),
Docker will refuse to start with an "address already in use" error; pick
another host port, e.g. `-p 127.0.0.1:8890:8888`, and open
`http://127.0.0.1:8890/lab?token=...` instead. The explicit `127.0.0.1:` in
the port mapping matters: it keeps the server off your network interfaces,
and it makes a port collision fail loudly instead of silently routing your
browser to the other Jupyter server. JupyterLab opens on the notebook with its dependencies already
installed; the install cell at the top is a no-op and can be skipped. The
notebooks stream data from the DANDI Archive, so network access is still
required at run time.

Images are built for `linux/amd64`, the platform the dependency pins were
resolved for. On Apple Silicon, Docker Desktop runs them under emulation;
pass `--platform linux/amd64` to silence the platform warning.
Images are multi-arch (`linux/amd64` and `linux/arm64`), so Apple Silicon
machines run them natively rather than under emulation. Emulated execution is
not just slow: Rosetta can deadlock on subprocess spawns inside the kernel,
which shows up as cells hanging forever. If you previously pulled an
amd64-only version of an image, run `docker pull` again to pick up the
multi-arch manifest.

Images are named after the notebook directory (lowercased, with `/` replaced
by `-`). When notebooks in the same directory pin different dependency sets,
Expand Down Expand Up @@ -55,7 +64,14 @@ cell and the CI harness. JupyterLab runs from an isolated `uv tool`
environment so its own dependencies cannot perturb the pinned kernel
environment. `ipykernel` and `nbformat` are the only additions to the pinned
set; they resolve jointly with the pins so a conflict fails the build rather
than silently changing a pinned version.
than silently changing a pinned version. The matplotlib font cache is built
at image-build time so the first import in a fresh container doesn't stall
on it.

Each architecture is built and verified on its own native runner
(`ubuntu-latest` for amd64, `ubuntu-24.04-arm` for arm64) — no emulation
anywhere in the pipeline — then the per-arch images are merged into one
multi-arch manifest carrying the user-facing tags.

## Maintainer Notes

Expand All @@ -72,7 +88,13 @@ than silently changing a pinned version.
bump it, update the digest, which changes every group's build hash, and
dispatch a full rebuild. The `BASE_IMAGE` arg is also the knob for a future
variant based on the official Colab runtime image.
- Images run as root (`python:3.12-slim` has no unprivileged user) and keep
Jupyter's token auth enabled. The published port binding in the docs is
loopback-only via `-p 8888:8888` on a local machine; advise users not to
bind on public interfaces.
- The server runs as the non-root user `jovyan` (uid 1000) with Jupyter's
token auth enabled. CI's verification step runs the container with
`--user root` so the harness's transient installs can write to the system
Python; the published default stays non-root. The documented port mapping
binds the host side to `127.0.0.1` explicitly; a bare `-p 8888:8888` would
bind all interfaces, and on macOS it also loses silently to any local
Jupyter server already listening on `127.0.0.1:8888`.
- The `hash-<12 hex>-amd64` / `-arm64` tags are the per-arch build artifacts
the merge step assembles into the multi-arch `hash-<12 hex>` manifest; they
also serve as the per-arch skip markers.
87 changes: 69 additions & 18 deletions .github/workflows/build-notebook-images.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: Build notebook images

# Builds a container image per notebook group (directory + identical pin set),
# verifies every notebook in the group runs inside the candidate image using
# the same harness as the test workflows, and pushes verified images to
# ghcr.io. Only verified images are ever published.
# Builds a container image per notebook group (directory + identical pin set)
# for both amd64 and arm64 on native runners, verifies every notebook in the
# group inside the candidate image using the same harness as the test
# workflows, pushes per-arch images, and merges them into one multi-arch
# manifest on ghcr.io. Only verified images are ever published.

on:
workflow_dispatch:
Expand Down Expand Up @@ -51,13 +52,16 @@ jobs:
build:
needs: list
if: needs.list.outputs.groups != '[]'
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner.os }}
timeout-minutes: 90
strategy:
fail-fast: false
max-parallel: 6
matrix:
group: ${{ fromJSON(needs.list.outputs.groups) }}
runner:
- { os: ubuntu-latest, arch: amd64 }
- { os: ubuntu-24.04-arm, arch: arm64 }
steps:
- uses: actions/checkout@v4

Expand All @@ -83,11 +87,11 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Skip if an image with this build hash is already published
- name: Skip if this build hash is already published for this arch
id: check
run: |
IMAGE='${{ steps.prepare.outputs.image }}'
TAG='hash-${{ steps.prepare.outputs.build_hash_short }}'
TAG='hash-${{ steps.prepare.outputs.build_hash_short }}-${{ matrix.runner.arch }}'
if [ '${{ inputs.force }}' != 'true' ] \
&& docker manifest inspect "$IMAGE:$TAG" >/dev/null 2>&1; then
echo "Image $IMAGE:$TAG already exists; skipping build and push."
Expand All @@ -100,7 +104,7 @@ jobs:
if: steps.check.outputs.up_to_date == 'false'
run: |
docker build \
--platform linux/amd64 \
--platform 'linux/${{ matrix.runner.arch }}' \
-f .github/docker/Dockerfile \
--build-arg DEFAULT_NOTEBOOK='${{ steps.prepare.outputs.default_notebook }}' \
--build-arg BUILD_HASH='${{ steps.prepare.outputs.build_hash }}' \
Expand All @@ -111,13 +115,15 @@ jobs:

- name: Verify notebooks inside the candidate image
if: steps.check.outputs.up_to_date == 'false'
# --user root so the harness's transient uv installs can write to
# /usr/local; the pushed image still defaults to the jovyan user.
run: |
mkdir -p "$RUNNER_TEMP/verify"
echo '${{ steps.prepare.outputs.notebooks }}' \
| python3 -c 'import json, sys; print("\n".join(json.load(sys.stdin)))' \
| while IFS= read -r nb; do
echo "::group::verify $nb"
docker run --rm \
docker run --rm --user root \
-v "$PWD/.github/scripts/run_notebook.py:/ci/run_notebook.py:ro" \
-v "$RUNNER_TEMP/verify:/out" \
'${{ steps.prepare.outputs.image }}:candidate' \
Expand All @@ -129,23 +135,68 @@ jobs:
if: always() && steps.check.outputs.up_to_date == 'false'
uses: actions/upload-artifact@v4
with:
name: verify-${{ matrix.group }}
name: verify-${{ matrix.group }}-${{ matrix.runner.arch }}
path: ${{ runner.temp }}/verify/
retention-days: 30
if-no-files-found: ignore

- name: Push verified image
- name: Push verified per-arch image
if: steps.check.outputs.up_to_date == 'false' && inputs.push
run: |
IMAGE='${{ steps.prepare.outputs.image }}'
for tag in latest \
"sha-$(echo '${{ github.sha }}' | cut -c1-7)" \
"$(date -u +%Y-%m-%d)" \
'hash-${{ steps.prepare.outputs.build_hash_short }}'; do
docker tag "$IMAGE:candidate" "$IMAGE:$tag"
docker push "$IMAGE:$tag"
done
TAG='hash-${{ steps.prepare.outputs.build_hash_short }}-${{ matrix.runner.arch }}'
docker tag "$IMAGE:candidate" "$IMAGE:$TAG"
docker push "$IMAGE:$TAG"

- name: Disk usage telemetry
if: always()
run: df -h

merge:
needs: [list, build]
if: needs.list.outputs.groups != '[]' && inputs.push
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
group: ${{ fromJSON(needs.list.outputs.groups) }}
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install runner dependencies
run: pip install --no-cache-dir nbformat

- name: Recompute image name and build hash
id: prepare
run: |
python .github/scripts/build_notebook_image.py prepare \
--name '${{ matrix.group }}' \
--context-dir "$RUNNER_TEMP/context" \
--image-prefix "ghcr.io/${{ github.repository }}"

- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Merge per-arch images into a multi-arch manifest
run: |
IMAGE='${{ steps.prepare.outputs.image }}'
H='hash-${{ steps.prepare.outputs.build_hash_short }}'
if [ '${{ inputs.force }}' != 'true' ] \
&& docker manifest inspect "$IMAGE:$H" >/dev/null 2>&1; then
echo "Manifest $IMAGE:$H already exists; skipping merge."
exit 0
fi
docker buildx imagetools create \
-t "$IMAGE:latest" \
-t "$IMAGE:sha-$(echo '${{ github.sha }}' | cut -c1-7)" \
-t "$IMAGE:$(date -u +%Y-%m-%d)" \
-t "$IMAGE:$H" \
"$IMAGE:$H-amd64" "$IMAGE:$H-arm64"
Loading