From b864f2f7f2a8fa7e8ffbd7e952f734c7154f0241 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:57:22 -0500 Subject: [PATCH 1/5] build: bump jupyterlab-launchpad to 1.1.1 in jupyterlab image Picks up the latest upstream release. Dependency set is unchanged from 1.1.0, so only the launchpad wheel entry moves in the lock file. --- images/jupyterlab/pixi.lock | 10 +++++----- images/jupyterlab/pixi.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/images/jupyterlab/pixi.lock b/images/jupyterlab/pixi.lock index 6cfbf3f..459bfc7 100644 --- a/images/jupyterlab/pixi.lock +++ b/images/jupyterlab/pixi.lock @@ -484,7 +484,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/3e/8228047497767397d5e9ef195fb2739b6ed755d042ad3c0edb28f4f6d578/conda_lock-3.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d8/3a/33189fc5f72f31d334345dfeca7a634c4949ad20e1c5093188f25e2c9c24/jupyterlab_launchpad-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/da/e5/148ab5edb339f5833d04f0bcb8380a53e8b19bd5f091ae67222ed188b393/uv-0.9.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -963,7 +963,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/3e/8228047497767397d5e9ef195fb2739b6ed755d042ad3c0edb28f4f6d578/conda_lock-3.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d8/3a/33189fc5f72f31d334345dfeca7a634c4949ad20e1c5093188f25e2c9c24/jupyterlab_launchpad-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl @@ -9791,10 +9791,10 @@ packages: - xattr>=1.0.0,<2.0.0 ; sys_platform == 'darwin' - zstandard>=0.15 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/d8/3a/33189fc5f72f31d334345dfeca7a634c4949ad20e1c5093188f25e2c9c24/jupyterlab_launchpad-1.1.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl name: jupyterlab-launchpad - version: 1.1.0 - sha256: cd8ca17985e961cdb836f3d6335c640724aacebbf6adef5bd163f3f397106e33 + version: 1.1.1 + sha256: 24a7af666d42f552491ae01d00a1d18d5dcd88b547e470a7ad00f5c08d4cc6ba requires_dist: - jupyter-server>=2.0.1,<3 - jupyter-server-proxy ; extra == 'test' diff --git a/images/jupyterlab/pixi.toml b/images/jupyterlab/pixi.toml index 72f5970..c0d282d 100644 --- a/images/jupyterlab/pixi.toml +++ b/images/jupyterlab/pixi.toml @@ -78,6 +78,6 @@ jupyterlab_nvdashboard = "==0.12.0" # argo-jupyter-scheduler = "==2024.6.1" # disabled until service is configured jhub-apps = "==2026.8.1" jupyterlab-nebari-mode = "==0.3.0" -jupyterlab-launchpad = "==1.1.0" +jupyterlab-launchpad = "==1.1.1" jupyterlab-gallery = "==0.6.3" jupyterlab-jhub-apps = "==0.3.1" From 793ba182c31ca2b70f13c1978fd858f50491e48a Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:40:48 -0500 Subject: [PATCH 2/5] fix: address PR review feedback - regenerate pixi.lock with pixi update jupyterlab-launchpad so entries keep pixi's sort order - document that launchpad >=1.1.1 depends on the nebi binary >= v0.15 --- images/jupyterlab/pixi.lock | 20 ++++++++++---------- images/jupyterlab/pixi.toml | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/images/jupyterlab/pixi.lock b/images/jupyterlab/pixi.lock index 459bfc7..0f5ff60 100644 --- a/images/jupyterlab/pixi.lock +++ b/images/jupyterlab/pixi.lock @@ -448,6 +448,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/f6/73c4aa003d1237ee9bea8a46f49dc38c45dfe95af4f0da7e60678d388011/trove_classifiers-2025.11.14.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/5b/16e50abf152be7f18120f11dfff495014a9eaff7b764626e1656f04ad262/pynvml-11.5.3-py3-none-any.whl @@ -484,7 +485,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/3e/8228047497767397d5e9ef195fb2739b6ed755d042ad3c0edb28f4f6d578/conda_lock-3.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/da/e5/148ab5edb339f5833d04f0bcb8380a53e8b19bd5f091ae67222ed188b393/uv-0.9.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -927,6 +927,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/f6/73c4aa003d1237ee9bea8a46f49dc38c45dfe95af4f0da7e60678d388011/trove_classifiers-2025.11.14.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/5b/16e50abf152be7f18120f11dfff495014a9eaff7b764626e1656f04ad262/pynvml-11.5.3-py3-none-any.whl @@ -963,7 +964,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/3e/8228047497767397d5e9ef195fb2739b6ed755d042ad3c0edb28f4f6d578/conda_lock-3.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl @@ -9296,6 +9296,14 @@ packages: version: 25.0.0 sha256: dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl + name: jupyterlab-launchpad + version: 1.1.1 + sha256: 24a7af666d42f552491ae01d00a1d18d5dcd88b547e470a7ad00f5c08d4cc6ba + requires_dist: + - jupyter-server>=2.0.1,<3 + - jupyter-server-proxy ; extra == 'test' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/49/f6/73c4aa003d1237ee9bea8a46f49dc38c45dfe95af4f0da7e60678d388011/trove_classifiers-2025.11.14.15-py3-none-any.whl name: trove-classifiers version: 2025.11.14.15 @@ -9791,14 +9799,6 @@ packages: - xattr>=1.0.0,<2.0.0 ; sys_platform == 'darwin' - zstandard>=0.15 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/47/8c/bb941f9251290a3f01b75124530fd102bfd51948c30c0cdf7da00644f785/jupyterlab_launchpad-1.1.1-py3-none-any.whl - name: jupyterlab-launchpad - version: 1.1.1 - sha256: 24a7af666d42f552491ae01d00a1d18d5dcd88b547e470a7ad00f5c08d4cc6ba - requires_dist: - - jupyter-server>=2.0.1,<3 - - jupyter-server-proxy ; extra == 'test' - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl name: tqdm version: 4.68.3 diff --git a/images/jupyterlab/pixi.toml b/images/jupyterlab/pixi.toml index c0d282d..18a646b 100644 --- a/images/jupyterlab/pixi.toml +++ b/images/jupyterlab/pixi.toml @@ -78,6 +78,8 @@ jupyterlab_nvdashboard = "==0.12.0" # argo-jupyter-scheduler = "==2024.6.1" # disabled until service is configured jhub-apps = "==2026.8.1" jupyterlab-nebari-mode = "==0.3.0" +# >=1.1.1 listens for nebi:job-completed to refresh kernels; the sender is the +# nebi binary pinned at values.yaml nebi.image.tag, which must be >= v0.15. jupyterlab-launchpad = "==1.1.1" jupyterlab-gallery = "==0.6.3" jupyterlab-jhub-apps = "==0.3.1" From 26b59e9f17b861c7e068f7946989095dc471dc83 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:46:39 -0500 Subject: [PATCH 3/5] chore: point values.yaml at PR build sha-7c0599c so e2e tests 1.1.1 Temporary: the post-merge bump-image-tags workflow rewrites these. --- values.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/values.yaml b/values.yaml index ce05883..6c92743 100644 --- a/values.yaml +++ b/values.yaml @@ -504,7 +504,7 @@ jupyterhub: # The same value sits inside profile_options.image.choices.default # so the JupyterLab profile selector keeps showing it too. # scripts/bump_image_tags.py syncs all three on every bump. - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-08ea5e4 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-7c0599c cpu_limit: 1 cpu_guarantee: 0.5 mem_limit: "2G" @@ -514,15 +514,15 @@ jupyterhub: display_name: Image choices: default: - display_name: "nebari-data-science-pack-jupyterlab:sha-08ea5e4" + display_name: "nebari-data-science-pack-jupyterlab:sha-7c0599c" default: true kubespawner_override: - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-08ea5e4 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-7c0599c - slug: medium-instance display_name: "Medium Instance" description: "4 CPU / 8 GB RAM — pandas / scikit-learn workloads on medium datasets." kubespawner_override: - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-08ea5e4 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-7c0599c cpu_limit: 4 cpu_guarantee: 2 mem_limit: "8G" @@ -532,10 +532,10 @@ jupyterhub: display_name: Image choices: default: - display_name: "nebari-data-science-pack-jupyterlab:sha-08ea5e4" + display_name: "nebari-data-science-pack-jupyterlab:sha-7c0599c" default: true kubespawner_override: - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-08ea5e4 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-7c0599c # Terminal customization: controls Starship prompt in JupyterLab terminals. # When false, falls back to the default bash prompt. terminal-customization: true @@ -597,7 +597,7 @@ jupyterhub: # class clears current_user — id_token_hint now reaches KC). image: name: quay.io/nebari/nebari-data-science-pack-jupyterhub - tag: "sha-08ea5e4" + tag: "sha-7c0599c" config: JupyterHub: @@ -700,7 +700,7 @@ jupyterhub: # keeps the two in sync on every automated bump instead. initContainers: - name: merge-ca-bundle - image: quay.io/nebari/nebari-data-science-pack-jupyterhub:sha-08ea5e4 + image: quay.io/nebari/nebari-data-science-pack-jupyterhub:sha-7c0599c imagePullPolicy: IfNotPresent # z2jh's pod-level securityContext sets ``runAsNonRoot: true`` but # no explicit uid, and the hub image's default ``USER`` is root, @@ -767,7 +767,7 @@ jupyterhub: singleuser: image: name: quay.io/nebari/nebari-data-science-pack-jupyterlab - tag: "sha-08ea5e4" + tag: "sha-7c0599c" defaultUrl: "/lab" extraEnv: JUPYTERHUB_SINGLEUSER_APP: "jupyter_server.serverapp.ServerApp" From c0a72e35ead4c51ec0cc58967c8ca9314938ca51 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:22:56 -0500 Subject: [PATCH 4/5] fix: address review feedback on nebi version floor, image-ref sync, CI smoke test - Document the nebi >= v0.15 floor where deployers look: comment at values.yaml nebi.image.tag, override prose in nebi-integration.md, and a troubleshooting row for the silent ~61s fallback (including the slow-start mode fallback race). - Fix the profiles NOTE comment to name all three refs a hand-bump must move (outer kubespawner_override.image, choices.default image, and its display_name). - Add tests/unit/test_image_ref_sync.py: profile images and choice display_names must match singleuser.image, and hub.image.tag must match singleuser.image.tag, so a half-bump fails the unit job instead of e2e passing against a stale image. - Add a post-build smoke step to build-image.yaml (opt-in via smoke_test_labextensions, enabled for the jupyterlab image): run jupyter labextension list in the pushed digest and assert the jupyterlab-launchpad version pinned in pixi.toml is enabled. --- .github/workflows/build-image.yaml | 23 +++++++ .github/workflows/build-images.yaml | 1 + docs/src/content/docs/nebi-integration.md | 6 ++ tests/unit/test_image_ref_sync.py | 84 +++++++++++++++++++++++ values.yaml | 15 ++-- 5 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_image_ref_sync.py diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 1b11bcb..4001e7e 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -37,6 +37,11 @@ on: required: false default: "" type: string + smoke_test_labextensions: + description: "After each arch build, run `jupyter labextension list` in the pushed image and assert the jupyterlab-launchpad version pinned in images/jupyterlab/pixi.toml is enabled. Only meaningful for jupyterlab-target images. Skipped on fork PRs (no pushed image to run)." + required: false + default: false + type: boolean secrets: QUAY_TOKEN: required: false @@ -96,6 +101,15 @@ jobs: GPU=${{ inputs.gpu }} NEBI_IMAGE=${{ inputs.nebi_image }} + - name: "Smoke test: labextensions enabled at pinned versions" + if: env.IS_FORK_PR != 'true' && inputs.smoke_test_labextensions + run: | + expected=$(sed -nE 's/^jupyterlab-launchpad = "==([^"]+)"$/\1/p' images/jupyterlab/pixi.toml) + test -n "$expected" || { echo "could not read jupyterlab-launchpad pin from pixi.toml"; exit 1; } + out=$(docker run --rm "ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}" jupyter labextension list 2>&1) + printf '%s\n' "$out" + grep -F "jupyterlab-launchpad v${expected}" <<< "$out" + - name: "Export digest" if: env.IS_FORK_PR != 'true' run: | @@ -150,6 +164,15 @@ jobs: GPU=${{ inputs.gpu }} NEBI_IMAGE=${{ inputs.nebi_image }} + - name: "Smoke test: labextensions enabled at pinned versions" + if: env.IS_FORK_PR != 'true' && inputs.smoke_test_labextensions + run: | + expected=$(sed -nE 's/^jupyterlab-launchpad = "==([^"]+)"$/\1/p' images/jupyterlab/pixi.toml) + test -n "$expected" || { echo "could not read jupyterlab-launchpad pin from pixi.toml"; exit 1; } + out=$(docker run --rm "ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}" jupyter labextension list 2>&1) + printf '%s\n' "$out" + grep -F "jupyterlab-launchpad v${expected}" <<< "$out" + - name: "Export digest" if: env.IS_FORK_PR != 'true' run: | diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 9cd5700..cf716f0 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -43,6 +43,7 @@ jobs: with: image: nebari-data-science-pack-jupyterlab target: jupyterlab + smoke_test_labextensions: true secrets: QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} diff --git a/docs/src/content/docs/nebi-integration.md b/docs/src/content/docs/nebi-integration.md index 24fd65c..8015146 100644 --- a/docs/src/content/docs/nebi-integration.md +++ b/docs/src/content/docs/nebi-integration.md @@ -78,6 +78,11 @@ Leaving `tag` empty disables the init container entirely — no Nebi in user pod pinned per chart release (`scripts/bump_image_tags.py` only handles the JupyterLab images), so override it to test a PR build or to roll forward between chart releases. +When overriding, keep the binary **≥ v0.15**: `jupyterlab-launchpad` (≥ 1.1.1, pinned in +the JupyterLab image) listens for `nebi:job-completed` from this binary to refresh the +kernel list immediately after an environment build. Older nebi never sends the message, +so kernels silently fall back to the ~61s `KernelSpecManager` poll with no error logged. + ## The token exchange Nebi needs a per-user credential, and the hub is the only component holding the user's @@ -212,6 +217,7 @@ CA merge step is ordered before it, so the bundle is ready. See | Empty environment dropdown in jhub-apps | Exchange failure, or `auth_state` missing — `kubectl logs deploy/hub \| grep nebi-envs`. | | Registry changes not visible | Hub not restarted since the change. | | Apps missing packages in a Nebi env | `jhub-app-proxy-version` below v0.2.3. | +| New kernels take ~61s to appear after an env build | `nebi.image.tag` below v0.15 (no `nebi:job-completed` sender), launchpad below 1.1.1, the Nebi tab not open when the build finished, or nebi fell back to team mode after a slow start (`/version` unreachable for ~900ms at handshake time) — all fail silently to the poll. | ```bash # Is the binary in the pod? diff --git a/tests/unit/test_image_ref_sync.py b/tests/unit/test_image_ref_sync.py new file mode 100644 index 0000000..abcab95 --- /dev/null +++ b/tests/unit/test_image_ref_sync.py @@ -0,0 +1,84 @@ +"""Structural tests that every hand-editable image reference in values.yaml +agrees with ``jupyterhub.singleuser.image``. + +e2e derives its cache key and kind side-load from ``singleuser.image``, but +the pod that actually spawns comes from the *default profile*: the spawn +POST has no body, so kubespawner falls through to the ``default: true`` +profile, whose ``profile_options`` default choice overwrites the image. +A bump that moves ``singleuser.image.tag`` but misses a profile ref would +therefore have e2e report the new tag while the pod pulls the old image — +green CI on stale code. ``scripts/bump_image_tags.py`` keeps these in sync +on the automated path; these asserts catch the hand-edit path. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +VALUES_YAML = REPO_ROOT / "values.yaml" + + +def _jupyterhub_values(): + with VALUES_YAML.open() as f: + return yaml.safe_load(f)["jupyterhub"] + + +def _singleuser_ref(jh): + image = jh["singleuser"]["image"] + return f'{image["name"]}:{image["tag"]}' + + +def test_profile_images_match_singleuser(): + """Every profile's outer kubespawner_override.image AND its + profile_options default-choice image must equal singleuser.image — + the default choice is what the spawned pod actually runs.""" + jh = _jupyterhub_values() + ref = _singleuser_ref(jh) + profiles = jh["custom"]["profiles"] + assert profiles, "no profiles found under jupyterhub.custom.profiles" + for profile in profiles: + slug = profile["slug"] + assert profile["kubespawner_override"]["image"] == ref, ( + f"profile {slug!r}: kubespawner_override.image does not match " + f"singleuser.image ({ref}) — jhub-apps' Create App shows this " + "value; a half-bump here spawns a stale image" + ) + choices = profile["profile_options"]["image"]["choices"] + for name, choice in choices.items(): + assert choice["kubespawner_override"]["image"] == ref, ( + f"profile {slug!r} choice {name!r}: image does not match " + f"singleuser.image ({ref}) — this choice overwrites the pod " + "image at spawn, so e2e would report the new tag while the " + "pod pulls the old one" + ) + + +def test_profile_choice_display_names_match_image(): + """The default choice's display_name embeds the tag the profile selector + shows; it must name the image the choice actually spawns.""" + jh = _jupyterhub_values() + image = jh["singleuser"]["image"] + expected = f'{image["name"].rsplit("/", 1)[-1]}:{image["tag"]}' + for profile in jh["custom"]["profiles"]: + choices = profile["profile_options"]["image"]["choices"] + for name, choice in choices.items(): + assert choice["display_name"] == expected, ( + f'profile {profile["slug"]!r} choice {name!r}: display_name ' + f'{choice["display_name"]!r} does not match the image it ' + f"spawns ({expected}) — the selector would show one tag and " + "run another" + ) + + +def test_hub_tag_matches_singleuser_tag(): + """hub and jupyterlab images are built from the same commit and tagged + with the same sha; a half-bump that moves the hub pair but not the + jupyterlab refs (or vice versa) must not pass unnoticed.""" + jh = _jupyterhub_values() + assert jh["hub"]["image"]["tag"] == jh["singleuser"]["image"]["tag"], ( + "hub.image.tag and singleuser.image.tag are bumped together from the " + "same commit's build; a mismatch means a partial hand-bump" + ) diff --git a/values.yaml b/values.yaml index 6c92743..338e254 100644 --- a/values.yaml +++ b/values.yaml @@ -253,6 +253,10 @@ nebi: # handles the jupyterlab images); deployers can override to test a PR # build or roll forward. Leave non-empty so the init container is wired # by default. sha-5ca877a == the nebi v0.15 tag commit. + # Must be >= v0.15: jupyterlab-launchpad >= 1.1.1 (pinned in + # images/jupyterlab/pixi.toml) listens for `nebi:job-completed` from this + # binary. Older nebi sends nothing, so kernel refresh silently falls back + # to the ~61s KernelSpecManager poll — no error is logged anywhere. tag: "sha-5ca877a" pullPolicy: IfNotPresent # External Nebi FQDN (browser-side, used for OIDC redirect). @@ -489,10 +493,13 @@ jupyterhub: # kubespawner_override: # extra_resource_limits: # nvidia.com/gpu: 1 - # NOTE: when bumping singleuser.image.tag below, also bump the - # ``image: ...`` lines inside each profile_options.image.choices.default - # entry so the profile selector shows the right tag. (z2jh values.yaml - # cannot reference other values, so the duplication is unavoidable.) + # NOTE: when bumping singleuser.image.tag below, also bump — in EACH + # profile — the outer ``kubespawner_override.image``, the ``image: ...`` + # line inside profile_options.image.choices.default, and that choice's + # ``display_name`` (it embeds the tag the selector shows). (z2jh + # values.yaml cannot reference other values, so the duplication is + # unavoidable.) tests/unit/test_image_ref_sync.py fails if any of them + # drift from singleuser.image. profiles: - slug: small-instance display_name: "Small Instance" From 67fa2f01fdc3ea74773d5f3f3bcd687f533ee142 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:05:00 -0500 Subject: [PATCH 5/5] fix: harden CI smoke step, scope image-ref test to jupyterlab refs, record nebi floor in reference docs - Smoke step: fail on 'disabled'/'enabled X' (strip unconditional ANSI from jupyter labextension list, match ' v enabled OK'), surface docker run failures instead of dying at the assignment with an empty log, and check all four pixi.toml-pinned extensions whose pin name matches the labextension name. Enable it for jupyterlab-gpu too (same pixi.toml via jupyterlab-base) and fix the input description. - test_image_ref_sync.py: mirror bump_image_tags.py's guards (imported JUPYTERLAB_IMAGE / JUPYTERLAB_DISPLAY_PREFIX) so documented non-default choices (e.g. the R image in server-profiles.md) and imageless variant profiles pass instead of failing or raising KeyError. New anchor test fails loudly if singleuser.image.name diverges from the script constant so the guards can't go vacuous. All six mutations still fire. - values-reference.md: nebi.image.tag row now carries the >= v0.15 floor (matching the jhub-app-proxy-version precedent in the same table). - server-profiles.md: 'hand edits have to keep up on their own' replaced with a pointer to the unit test; notes non-jupyterlab choices are left alone by both script and test. - nebi-integration.md troubleshooting row: split causes into checkable (with kubectl one-liners) vs no-signal (tab unmounted, team-mode race). --- .github/workflows/build-image.yaml | 64 ++++++++++++++--- .github/workflows/build-images.yaml | 1 + docs/src/content/docs/nebi-integration.md | 2 +- docs/src/content/docs/server-profiles.md | 6 +- docs/src/content/docs/values-reference.md | 2 +- tests/unit/test_image_ref_sync.py | 88 +++++++++++++++-------- 6 files changed, 118 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 4001e7e..8fb41d7 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -38,7 +38,7 @@ on: default: "" type: string smoke_test_labextensions: - description: "After each arch build, run `jupyter labextension list` in the pushed image and assert the jupyterlab-launchpad version pinned in images/jupyterlab/pixi.toml is enabled. Only meaningful for jupyterlab-target images. Skipped on fork PRs (no pushed image to run)." + description: "After each arch build, run `jupyter labextension list` in the pushed image and assert the extensions pinned in images/jupyterlab/pixi.toml are enabled at their pinned versions. Enable for every image built from the jupyterlab Dockerfile target (including the GPU variant — it installs the same pixi.toml). Skipped on fork PRs (no pushed image to run)." required: false default: false type: boolean @@ -104,11 +104,32 @@ jobs: - name: "Smoke test: labextensions enabled at pinned versions" if: env.IS_FORK_PR != 'true' && inputs.smoke_test_labextensions run: | - expected=$(sed -nE 's/^jupyterlab-launchpad = "==([^"]+)"$/\1/p' images/jupyterlab/pixi.toml) - test -n "$expected" || { echo "could not read jupyterlab-launchpad pin from pixi.toml"; exit 1; } - out=$(docker run --rm "ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}" jupyter labextension list 2>&1) - printf '%s\n' "$out" - grep -F "jupyterlab-launchpad v${expected}" <<< "$out" + ref="ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}" + # `jupyter labextension list` writes to stderr and colorizes the status + # words unconditionally (jupyter_server.extension.serverextension + # GREEN_ENABLED / RED_DISABLED / RED_X — guarded only by os.name != "nt"), + # so merge stderr and strip SGR sequences before matching. Keep grep -F + # substring matching: the real line has a trailing "(python, )" and + # may carry "*" or a lock emoji after OK. + if ! out=$(docker run --rm "$ref" jupyter labextension list 2>&1); then + printf '%s\n' "$out" + echo "::error::could not run 'jupyter labextension list' in $ref" + exit 1 + fi + plain=$(printf '%s\n' "$out" | sed -E 's/\x1b\[[0-9;]*m//g') + printf '%s\n' "$plain" + # Extensions whose pixi.toml pin name matches their labextension name. + # The list is hardcoded rather than derived from pixi.toml because the + # mapping isn't mechanical (jupyterlab_nvdashboard ships the extension + # as jupyterlab-nvdashboard; jhub-apps isn't a labextension at all). + for ext in jupyterlab-launchpad jupyterlab-nebari-mode jupyterlab-gallery jupyterlab-jhub-apps; do + expected=$(sed -nE "s/^${ext} = \"==([^\"]+)\"\$/\\1/p" images/jupyterlab/pixi.toml) + test -n "$expected" || { echo "::error::could not read exact ${ext} pin from images/jupyterlab/pixi.toml"; exit 1; } + grep -Fq " ${ext} v${expected} enabled OK" <<< "$plain" || { + echo "::error::${ext} v${expected} is not 'enabled OK' in $ref" + exit 1 + } + done - name: "Export digest" if: env.IS_FORK_PR != 'true' @@ -167,11 +188,32 @@ jobs: - name: "Smoke test: labextensions enabled at pinned versions" if: env.IS_FORK_PR != 'true' && inputs.smoke_test_labextensions run: | - expected=$(sed -nE 's/^jupyterlab-launchpad = "==([^"]+)"$/\1/p' images/jupyterlab/pixi.toml) - test -n "$expected" || { echo "could not read jupyterlab-launchpad pin from pixi.toml"; exit 1; } - out=$(docker run --rm "ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}" jupyter labextension list 2>&1) - printf '%s\n' "$out" - grep -F "jupyterlab-launchpad v${expected}" <<< "$out" + ref="ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}" + # `jupyter labextension list` writes to stderr and colorizes the status + # words unconditionally (jupyter_server.extension.serverextension + # GREEN_ENABLED / RED_DISABLED / RED_X — guarded only by os.name != "nt"), + # so merge stderr and strip SGR sequences before matching. Keep grep -F + # substring matching: the real line has a trailing "(python, )" and + # may carry "*" or a lock emoji after OK. + if ! out=$(docker run --rm "$ref" jupyter labextension list 2>&1); then + printf '%s\n' "$out" + echo "::error::could not run 'jupyter labextension list' in $ref" + exit 1 + fi + plain=$(printf '%s\n' "$out" | sed -E 's/\x1b\[[0-9;]*m//g') + printf '%s\n' "$plain" + # Extensions whose pixi.toml pin name matches their labextension name. + # The list is hardcoded rather than derived from pixi.toml because the + # mapping isn't mechanical (jupyterlab_nvdashboard ships the extension + # as jupyterlab-nvdashboard; jhub-apps isn't a labextension at all). + for ext in jupyterlab-launchpad jupyterlab-nebari-mode jupyterlab-gallery jupyterlab-jhub-apps; do + expected=$(sed -nE "s/^${ext} = \"==([^\"]+)\"\$/\\1/p" images/jupyterlab/pixi.toml) + test -n "$expected" || { echo "::error::could not read exact ${ext} pin from images/jupyterlab/pixi.toml"; exit 1; } + grep -Fq " ${ext} v${expected} enabled OK" <<< "$plain" || { + echo "::error::${ext} v${expected} is not 'enabled OK' in $ref" + exit 1 + } + done - name: "Export digest" if: env.IS_FORK_PR != 'true' diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index cf716f0..d1d1c20 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -68,6 +68,7 @@ jobs: build_arm64: false base_image: "nvidia/cuda:12.8.1-base-ubuntu24.04" gpu: true + smoke_test_labextensions: true secrets: QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} diff --git a/docs/src/content/docs/nebi-integration.md b/docs/src/content/docs/nebi-integration.md index 8015146..9a7316d 100644 --- a/docs/src/content/docs/nebi-integration.md +++ b/docs/src/content/docs/nebi-integration.md @@ -217,7 +217,7 @@ CA merge step is ordered before it, so the bundle is ready. See | Empty environment dropdown in jhub-apps | Exchange failure, or `auth_state` missing — `kubectl logs deploy/hub \| grep nebi-envs`. | | Registry changes not visible | Hub not restarted since the change. | | Apps missing packages in a Nebi env | `jhub-app-proxy-version` below v0.2.3. | -| New kernels take ~61s to appear after an env build | `nebi.image.tag` below v0.15 (no `nebi:job-completed` sender), launchpad below 1.1.1, the Nebi tab not open when the build finished, or nebi fell back to team mode after a slow start (`/version` unreachable for ~900ms at handshake time) — all fail silently to the poll. | +| New kernels take ~61s to appear after an env build | Checkable: `nebi.image.tag` below v0.15 (no `nebi:job-completed` sender — `kubectl -n data-science exec -- nebi --version`), or launchpad below 1.1.1 / disabled (`kubectl -n data-science exec -- jupyter labextension list 2>&1 \| grep launchpad`). Not checkable — no signal anywhere in this stack: the Nebi tab wasn't open when the build finished, or nebi fell back to team mode after a slow start (`/version` unreachable for ~900ms at handshake time). Re-test with the Nebi tab open. | ```bash # Is the binary in the pod? diff --git a/docs/src/content/docs/server-profiles.md b/docs/src/content/docs/server-profiles.md index 7693945..6c9c2a8 100644 --- a/docs/src/content/docs/server-profiles.md +++ b/docs/src/content/docs/server-profiles.md @@ -81,8 +81,10 @@ image field; the inner `profile_options.image.choices.default` is what the Jupyt profile selector shows. Both must be bumped alongside `jupyterhub.singleuser.image.tag`. z2jh values cannot reference other values, so the duplication is unavoidable. -`scripts/bump_image_tags.py` syncs all three on an automated bump — hand edits have to keep -up on their own. +`scripts/bump_image_tags.py` syncs all three on an automated bump, and +`tests/unit/test_image_ref_sync.py` fails CI if a hand edit lets any jupyterlab-tagged ref +in this repo's `values.yaml` drift from `singleuser.image`. Choices pointing at other +images (like the R image above) are left alone by both. ::: ## Gating profiles by group diff --git a/docs/src/content/docs/values-reference.md b/docs/src/content/docs/values-reference.md index 2b03f3f..64b0d8a 100644 --- a/docs/src/content/docs/values-reference.md +++ b/docs/src/content/docs/values-reference.md @@ -142,7 +142,7 @@ Admin guide: [Nebi integration](/nebi-integration/). | Field | Default | What it does | |---|---|---| | `nebi.image.repository` | `quay.io/nebari/nebi` | Binary copied into user pods by an init container. | -| `nebi.image.tag` | `sha-5ca877a` | Pinned per chart release. Empty disables the init container. | +| `nebi.image.tag` | `sha-5ca877a` | Pinned per chart release. Must be ≥ v0.15 for `jupyterlab-launchpad` (≥ 1.1.1) to receive `nebi:job-completed`; older nebi never sends it and kernel refresh silently falls back to the ~61s poll. Empty disables the init container. | | `nebi.image.pullPolicy` | `IfNotPresent` | — | | `nebi.remoteURL` | `""` *(derived)* | Browser-facing Nebi URL, used for the OIDC redirect. | | `nebi.internalURL` | `""` *(derived)* | In-cluster URL for the token-exchange path. | diff --git a/tests/unit/test_image_ref_sync.py b/tests/unit/test_image_ref_sync.py index abcab95..490bd06 100644 --- a/tests/unit/test_image_ref_sync.py +++ b/tests/unit/test_image_ref_sync.py @@ -1,5 +1,5 @@ -"""Structural tests that every hand-editable image reference in values.yaml -agrees with ``jupyterhub.singleuser.image``. +"""Structural tests that every hand-editable jupyterlab image reference in +values.yaml agrees with ``jupyterhub.singleuser.image``. e2e derives its cache key and kind side-load from ``singleuser.image``, but the pod that actually spawns comes from the *default profile*: the spawn @@ -9,6 +9,12 @@ therefore have e2e report the new tag while the pod pulls the old image — green CI on stale code. ``scripts/bump_image_tags.py`` keeps these in sync on the automated path; these asserts catch the hand-edit path. + +Scope mirrors the script's guards exactly: only refs pointing at the +jupyterlab image (``JUPYTERLAB_IMAGE:`` / ``JUPYTERLAB_DISPLAY_PREFIX:``) +must agree. Additional choices pointing elsewhere (e.g. the R image in +server-profiles.md) and variant profiles that carry no image are +intentionally out of scope, just as the script leaves them alone. """ from __future__ import annotations @@ -17,6 +23,8 @@ import yaml +from scripts.bump_image_tags import JUPYTERLAB_DISPLAY_PREFIX, JUPYTERLAB_IMAGE + REPO_ROOT = Path(__file__).resolve().parents[2] VALUES_YAML = REPO_ROOT / "values.yaml" @@ -31,46 +39,66 @@ def _singleuser_ref(jh): return f'{image["name"]}:{image["tag"]}' +def test_singleuser_image_is_the_jupyterlab_image(): + """Anchor for the guarded tests below: if singleuser.image were renamed + away from the script's JUPYTERLAB_IMAGE constant, every guard would stop + matching and the other tests would pass vacuously. Fail loudly instead.""" + jh = _jupyterhub_values() + assert jh["singleuser"]["image"]["name"] == JUPYTERLAB_IMAGE, ( + "singleuser.image.name no longer matches bump_image_tags.py's " + "JUPYTERLAB_IMAGE — update the script constant and these tests " + "together, or the sync guards silently stop guarding" + ) + + def test_profile_images_match_singleuser(): - """Every profile's outer kubespawner_override.image AND its - profile_options default-choice image must equal singleuser.image — - the default choice is what the spawned pod actually runs.""" + """Every jupyterlab-tagged image ref in the profiles — the outer + kubespawner_override.image and any profile_options choice pointing at + the jupyterlab image — must equal singleuser.image. The default choice + is what the spawned pod actually runs.""" jh = _jupyterhub_values() ref = _singleuser_ref(jh) profiles = jh["custom"]["profiles"] assert profiles, "no profiles found under jupyterhub.custom.profiles" for profile in profiles: slug = profile["slug"] - assert profile["kubespawner_override"]["image"] == ref, ( - f"profile {slug!r}: kubespawner_override.image does not match " - f"singleuser.image ({ref}) — jhub-apps' Create App shows this " - "value; a half-bump here spawns a stale image" - ) - choices = profile["profile_options"]["image"]["choices"] - for name, choice in choices.items(): - assert choice["kubespawner_override"]["image"] == ref, ( - f"profile {slug!r} choice {name!r}: image does not match " - f"singleuser.image ({ref}) — this choice overwrites the pod " - "image at spawn, so e2e would report the new tag while the " - "pod pulls the old one" + outer = profile.get("kubespawner_override", {}).get("image", "") + if outer.startswith(JUPYTERLAB_IMAGE + ":"): + assert outer == ref, ( + f"profile {slug!r}: kubespawner_override.image does not " + f"match singleuser.image ({ref}) — jhub-apps' Create App " + "shows this value; a half-bump here spawns a stale image" ) + options = profile.get("profile_options", {}).get("image", {}) + for name, choice in options.get("choices", {}).items(): + img = choice.get("kubespawner_override", {}).get("image", "") + if img.startswith(JUPYTERLAB_IMAGE + ":"): + assert img == ref, ( + f"profile {slug!r} choice {name!r}: image does not match " + f"singleuser.image ({ref}) — this choice overwrites the " + "pod image at spawn, so e2e would report the new tag " + "while the pod pulls the old one" + ) def test_profile_choice_display_names_match_image(): - """The default choice's display_name embeds the tag the profile selector - shows; it must name the image the choice actually spawns.""" + """Every choice whose display_name carries the jupyterlab image name + embeds the tag the profile selector shows; it must name the image + singleuser.image actually spawns.""" jh = _jupyterhub_values() - image = jh["singleuser"]["image"] - expected = f'{image["name"].rsplit("/", 1)[-1]}:{image["tag"]}' - for profile in jh["custom"]["profiles"]: - choices = profile["profile_options"]["image"]["choices"] - for name, choice in choices.items(): - assert choice["display_name"] == expected, ( - f'profile {profile["slug"]!r} choice {name!r}: display_name ' - f'{choice["display_name"]!r} does not match the image it ' - f"spawns ({expected}) — the selector would show one tag and " - "run another" - ) + tag = jh["singleuser"]["image"]["tag"] + expected = f"{JUPYTERLAB_DISPLAY_PREFIX}:{tag}" + for profile in _jupyterhub_values()["custom"]["profiles"]: + options = profile.get("profile_options", {}).get("image", {}) + for name, choice in options.get("choices", {}).items(): + display = choice.get("display_name", "") + if display.startswith(JUPYTERLAB_DISPLAY_PREFIX + ":"): + assert display == expected, ( + f'profile {profile["slug"]!r} choice {name!r}: ' + f"display_name {display!r} does not match the image it " + f"spawns ({expected}) — the selector would show one tag " + "and run another" + ) def test_hub_tag_matches_singleuser_tag():