From 0dff0bae17444907507a855cc7bdad2658ccd059 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Fri, 4 Sep 2026 10:10:17 +0200 Subject: [PATCH 01/14] feat(ci): add post-install contract checks --- .github/workflows/ci-build.yml | 33 +++ .github/workflows/ci-validate.yml | 2 +- docs/TESTING.md | 237 ++++++++++++++++++ features/java-kernel/test-java-kernel.sh | 49 ++++ solen-cli/solen/core/feature.py | 43 +++- solen-cli/solen/generators/dockerfile_gen.py | 10 + .../solen/generators/dockerfile_template.j2 | 88 +++++++ tests/test_postinstall_check_collection.py | 19 ++ 8 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 docs/TESTING.md create mode 100755 features/java-kernel/test-java-kernel.sh create mode 100644 tests/test_postinstall_check_collection.py diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 29e33a9..0663fa7 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -17,6 +17,14 @@ on: description: 'Disable buildx cache' required: false default: 'false' + run-postinstall-checks: + description: 'Run feature postInstallCheck commands in built images' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' permissions: contents: read @@ -74,6 +82,31 @@ jobs: echo "$profile" >> profiles-built.txt done + - name: Run post-install contract checks + env: + RUN_POSTINSTALL_CHECKS: ${{ github.event.inputs['run-postinstall-checks'] || 'false' }} + run: | + set -uo pipefail + if [ "$RUN_POSTINSTALL_CHECKS" != 'true' ]; then + echo "Post-install contract checks disabled" + exit 0 + fi + + failed=0 + while read -r profile; do + [ -n "$profile" ] || continue + tag=$(sed -n "/^target \"final-$profile\" /,/^}/p" generated/docker-bake.hcl \ + | sed -n '/tags = \[/,/\]/p' | grep -m1 '"' | tr -d '"') + if [ -z "$tag" ]; then + echo "No tag found in bake file for $profile; skipping post-install checks" >&2 + continue + fi + echo "Running post-install contract checks for $tag" + docker run --rm --entrypoint /opt/solen/run-postinstall-checks.sh "$tag" || failed=1 + done < profiles-built.txt + + exit "$failed" + - name: Generate SBOM and Trivy scans run: | set -uo pipefail diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 235b890..0500678 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -41,4 +41,4 @@ jobs: run: python -m pytest tests/ -q - name: Lint - run: ruff check solen-cli + run: ruff check solen-cli tests diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..c3763df --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,237 @@ +# Testing + +This document describes the current testing strategy for Solen and the post-install contract checks added for feature validation. + +## Test layers + +Solen testing is split into several layers: + +1. **Static validation** + - feature schema validation + - profile generation + - version synchronization + - Python unit tests + - linting + +2. **Feature contract tests** + - each feature can declare a `postInstallCheck` + - generated Dockerfiles can run those checks during or after image build + - checks are intended to verify that a feature is usable in the final container + +3. **Feature test scripts** + - features may include `*test*.sh` scripts + - scripts are staged into the image under `/opt/solen/feature-tests//` + - `/opt/solen/run-feature-tests.sh` executes all staged feature tests + +4. **CI build validation** + - canonical builds run in GitHub Actions with Docker BuildKit + - post-install checks can be enabled per build run + +## Local setup + +Create a virtual environment and install the CLI with development dependencies: + +```bash +python3 -m venv .venv +.venv/bin/pip install --upgrade pip +.venv/bin/pip install -e 'solen-cli[dev]' +``` + +Optional, for local mypy checks: + +```bash +.venv/bin/pip install types-PyYAML +``` + +## Static validation + +Run these commands from the repository root: + +```bash +.venv/bin/python -m pytest tests -q +.venv/bin/ruff check solen-cli tests +.venv/bin/solen validate features +.venv/bin/solen versions sync --check +``` + +Generate profiles locally: + +```bash +.venv/bin/solen generate profiles --matrix profiles/matrix --out /tmp/generated-profiles --chain +``` + +For local work, prefer generating profiles into a temporary directory rather than directly into `generated/profiles/`. + +## Unit tests + +The `tests/` directory contains pytest-based validation for generator and feature contract helpers. + +Current relevant tests include: + +- `tests/test_postinstall_checks.py` +- `tests/test_postinstall_check_collection.py` + +Run them with: + +```bash +.venv/bin/python -m pytest tests -q +``` + +## Feature post-install checks + +A feature can declare a post-install check in `feature.json`: + +```json +{ + "id": "java-kernel", + "postInstallCheck": { + "command": "bash -lc '...'", + "description": "Verify Java Jupyter kernelspec is registered" + } +} +``` + +The generated Dockerfile embeds a runner at: + +```text +/opt/solen/run-postinstall-checks.sh +``` + +Behavior: + +- commands are base64-encoded to avoid shell escaping issues +- checks run as `${NB_USER:-jovyan}` +- the runner prefers `runuser`, then `setpriv`, then `su` +- all configured checks are executed +- the runner exits with the number of failed checks + +The behavior is controlled by: + +```dockerfile +ARG RUN_POSTINSTALL_CHECKS="false" +ENV RUN_POSTINSTALL_CHECKS="${RUN_POSTINSTALL_CHECKS}" +``` + +When enabled, the generated image also: + +- runs per-feature post-install checks immediately after each feature install +- runs the full post-install check runner near the end of the install stage + +## Feature test scripts + +Features may include test scripts such as: + +```text +features/java-kernel/test-java-kernel.sh +``` + +Conventions: + +- use bash +- fail fast with `set -euo pipefail` +- verify the runtime artifacts that the feature is supposed to provide +- avoid assumptions about login-shell state where possible +- target the non-root user when relevant + +Example: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +java_bin="$(command -v java || true)" +if [ -z "$java_bin" ]; then + java_bin="/home/${NB_USER:-jovyan}/.sdkman/candidates/java/current/bin/java" +fi + +if [ ! -x "$java_bin" ]; then + echo "ERROR: java runtime not found" >&2 + exit 1 +fi + +"$java_bin" -version +``` + +Generated images stage these scripts into: + +```text +/opt/solen/feature-tests// +``` + +and provide: + +```text +/opt/solen/run-feature-tests.sh +``` + +To run all staged feature tests in a built image: + +```bash +docker run --rm --entrypoint /opt/solen/run-feature-tests.sh +``` + +## CI validation + +### `ci-validate.yml` + +The validation workflow runs static checks, including: + +- `solen validate features` +- profile generation +- devcontainer generation +- version synchronization +- pytest +- ruff over `solen-cli` and `tests` + +### `ci-build.yml` + +The build workflow: + +- generates profiles +- generates `generated/Dockerfile` +- generates `generated/docker-bake.hcl` +- builds the selected profiles +- optionally runs post-install checks in the built images + +The post-install check gate is controlled by the workflow input: + +```yaml +run-postinstall-checks: + description: 'Run feature postInstallCheck commands in built images' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' +``` + +When enabled, CI runs: + +```bash +docker run --rm --entrypoint /opt/solen/run-postinstall-checks.sh +``` + +for each built profile. + +## Local limitations + +Some validations are easier or only possible in CI: + +- canonical image builds require Docker BuildKit and `docker buildx bake` +- feature runtime checks require the built image +- Java-related checks require a Java-enabled profile +- Jupyter/kernel checks require the relevant Python/Jupyter stack + +If local Docker/BuildKit support is unavailable, rely on CI for image-level validation. + +## Roadmap + +Future testing work should extend the current slice with: + +- E2E/BDD scenarios for full profile builds +- automated smoke tests for `quarto-full` +- optional SonarQube quality gates +- Harbor image admission checks +- public registry validation +- richer test reporting for CI artifacts diff --git a/features/java-kernel/test-java-kernel.sh b/features/java-kernel/test-java-kernel.sh new file mode 100755 index 0000000..416716c --- /dev/null +++ b/features/java-kernel/test-java-kernel.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Running java-kernel feature tests" + +NB_USER="${NB_USER:-jovyan}" + +java_bin="$(command -v java || true)" +if [ -z "$java_bin" ]; then + java_bin="/home/${NB_USER}/.sdkman/candidates/java/current/bin/java" +fi + +if [ ! -x "$java_bin" ]; then + echo "ERROR: java runtime not found" >&2 + exit 1 +fi + +"$java_bin" -version + +kernel_dir="/usr/local/share/jupyter/kernels/java" +if [ -d "$kernel_dir" ]; then + echo "Found IJava kernelspec at $kernel_dir" +else + jupyter_bin="$(command -v jupyter || true)" + if [ -z "$jupyter_bin" ]; then + for candidate in "/home/${NB_USER}/miniforge3/bin/jupyter" "/usr/local/bin/jupyter"; do + if [ -x "$candidate" ]; then + jupyter_bin="$candidate" + break + fi + done + fi + + if [ -z "$jupyter_bin" ]; then + echo "ERROR: IJava kernelspec not found and jupyter CLI is unavailable" >&2 + exit 1 + fi + + "$jupyter_bin" kernelspec list --json | python3 -c ' +import json +import sys + +kernels = json.load(sys.stdin) +if "java" not in kernels: + raise SystemExit("ERROR: java kernelspec not registered") +' +fi + +echo "java-kernel feature tests passed" diff --git a/solen-cli/solen/core/feature.py b/solen-cli/solen/core/feature.py index c489886..fa5b739 100644 --- a/solen-cli/solen/core/feature.py +++ b/solen-cli/solen/core/feature.py @@ -185,8 +185,13 @@ def collect_feature_metadata(features_dir: Path, feature_list: list[str]) -> dic if not metadata: continue - if metadata.maintainer: - maintainers.add(metadata.maintainer) + maintainer = metadata.maintainer + if isinstance(maintainer, dict): + name = maintainer.get('name', '') + email = maintainer.get('email', '') + maintainer = f"{name} ({email})" if name and email else name or email + if maintainer: + maintainers.add(maintainer) platforms.update(metadata.platforms) provides.update(metadata.provides) @@ -198,6 +203,40 @@ def collect_feature_metadata(features_dir: Path, feature_list: list[str]) -> dic } +def collect_post_install_checks(features_dir: Path, feature_list: list[str]) -> list[dict[str, str]]: + """Collect postInstallCheck commands from features in install order.""" + checks: list[dict[str, str]] = [] + seen: set[str] = set() + + for feature_id in feature_list: + if feature_id in seen: + continue + seen.add(feature_id) + + feature_json = features_dir / feature_id / 'feature.json' + if not feature_json.exists(): + continue + + try: + data = json.loads(feature_json.read_text(encoding='utf-8')) + except Exception: + continue + + check = data.get('postInstallCheck') + if not isinstance(check, dict): + continue + + command = check.get('command') + if isinstance(command, str) and command.strip(): + checks.append({ + 'id': feature_id, + 'command': command.strip(), + 'description': str(check.get('description', '')), + }) + + return checks + + def collect_feature_options(features_dir: Path, feature_list: list[str]) -> dict[str, str]: """Collect default option values from all features. diff --git a/solen-cli/solen/generators/dockerfile_gen.py b/solen-cli/solen/generators/dockerfile_gen.py index 43f7de3..1b87e92 100644 --- a/solen-cli/solen/generators/dockerfile_gen.py +++ b/solen-cli/solen/generators/dockerfile_gen.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from pathlib import Path from typing import Any @@ -8,6 +9,7 @@ from solen.core.feature import ( collect_feature_metadata, collect_feature_options, + collect_post_install_checks, expand_feature_dependencies, topological_sort_features, ) @@ -45,6 +47,7 @@ def build_profile_data(repo_root: Path, profile_name: str) -> dict[str, Any]: metadata = collect_feature_metadata(features_dir, features) feature_options = collect_feature_options(features_dir, features) + checks = collect_post_install_checks(features_dir, features) all_options = {**feature_options, **profile_data.options} return { @@ -53,6 +56,13 @@ def build_profile_data(repo_root: Path, profile_name: str) -> dict[str, Any]: "features_str": " ".join(features) if features else "none", "provides_str": ",".join(metadata.get("provides", [])), "env": {k: docker_escape(v) for k, v in all_options.items()}, + "checks": [ + { + **check, + "command_b64": base64.b64encode(check["command"].encode("utf-8")).decode("ascii"), + } + for check in checks + ], } diff --git a/solen-cli/solen/generators/dockerfile_template.j2 b/solen-cli/solen/generators/dockerfile_template.j2 index 1007dc9..47f3520 100644 --- a/solen-cli/solen/generators/dockerfile_template.j2 +++ b/solen-cli/solen/generators/dockerfile_template.j2 @@ -38,6 +38,62 @@ LABEL org.solen.profile="{{ profile.name }}" \ ENV {{ k }}="{{ v }}" {% endfor %} +ARG RUN_POSTINSTALL_CHECKS="false" +ENV RUN_POSTINSTALL_CHECKS="${RUN_POSTINSTALL_CHECKS}" + +RUN cat > /opt/solen/run-postinstall-checks.sh <<'SCRIPT' +#!/usr/bin/env bash +set -uo pipefail + +features=( +{% for check in profile.checks %} + "{{ check.id }}" +{% endfor %} +) +commands_b64=( +{% for check in profile.checks %} + "{{ check.command_b64 }}" +{% endfor %} +) + +run_check_as_user() { + local check_cmd="$1" + local user="${NB_USER:-jovyan}" + if [ "$(id -un)" = "$user" ]; then + bash -lc "$check_cmd" + elif command -v runuser >/dev/null 2>&1; then + runuser -u "$user" -- bash -lc "$check_cmd" + elif command -v setpriv >/dev/null 2>&1; then + local uid + local gid + uid="$(id -u "$user")" + gid="$(id -g "$user")" + setpriv --reuid="$uid" --regid="$gid" --init-groups bash -lc "$check_cmd" + else + CHECK_CMD="$check_cmd" su -s /bin/bash "$user" -c 'bash -lc "$CHECK_CMD"' + fi +} + +total=0 +failed=0 +echo "Starting Solen post-install checks" +for i in "${!features[@]}"; do + feature="${features[$i]}" + check_cmd="$(printf '%s' "${commands_b64[$i]}" | base64 -d)" + total=$((total + 1)) + echo "=== RUN postInstallCheck: $feature" + if run_check_as_user "$check_cmd"; then + echo "OK postInstallCheck: $feature" + else + echo "FAIL postInstallCheck: $feature" >&2 + failed=$((failed + 1)) + fi +done +echo "Solen post-install checks complete: $((total - failed))/$total passed" +exit "$failed" +SCRIPT +RUN chmod +x /opt/solen/run-postinstall-checks.sh + RUN \ {% for feat in profile.features %} --mount=type=bind,source=features/{{ feat }},target=/tmp/features/{{ feat }},readonly \ @@ -60,12 +116,40 @@ echo "=== debug: helpers.sh head"; head -n 40 /opt/solen/_lib/helpers.sh || true echo "=== debug: fh_helpers.sh head"; head -n 40 /opt/solen/_lib/fh_helpers.sh || true echo "=== debug: fh_resolve_checksum exists?"; type -t fh_resolve_checksum || echo "fh_resolve_checksum: not-found" +if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ]; then + declare -A feature_check=() +{% for check in profile.checks %} + feature_check["{{ check.id }}"]="{{ check.command_b64 }}" +{% endfor %} + run_feature_check() { + local check_cmd="$1" + local user="${NB_USER:-jovyan}" + if [ "$(id -un)" = "$user" ]; then + bash -lc "$check_cmd" + elif command -v runuser >/dev/null 2>&1; then + runuser -u "$user" -- bash -lc "$check_cmd" + elif command -v setpriv >/dev/null 2>&1; then + local uid + local gid + uid="$(id -u "$user")" + gid="$(id -g "$user")" + setpriv --reuid="$uid" --regid="$gid" --init-groups bash -lc "$check_cmd" + else + CHECK_CMD="$check_cmd" su -s /bin/bash "$user" -c 'bash -lc "$CHECK_CMD"' + fi + } +fi + for f in "${feats[@]}"; do if [ -f "/tmp/features/$f/install.sh" ]; then chmod +x /tmp/features/$f/install.sh || true set +u bash /tmp/features/$f/install.sh set -u + if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ] && [ -n "${feature_check[$f]:-}" ]; then + check_cmd="$(printf '%s' "${feature_check[$f]}" | base64 -d)" + run_feature_check "$check_cmd" + fi fi done @@ -83,6 +167,10 @@ for f in "${feats[@]}"; do fi done +if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ]; then + /opt/solen/run-postinstall-checks.sh +fi + apt-get clean rm -rf /var/lib/apt/lists/auxfiles /var/lib/apt/lists/lock /var/lib/apt/lists/partial SCRIPT diff --git a/tests/test_postinstall_check_collection.py b/tests/test_postinstall_check_collection.py new file mode 100644 index 0000000..d7c943e --- /dev/null +++ b/tests/test_postinstall_check_collection.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from solen.core.feature import collect_post_install_checks + + +def test_collect_post_install_checks_returns_java_kernel_command(): + repo_root = Path(__file__).resolve().parents[1] + checks = collect_post_install_checks(repo_root / 'features', ['java-kernel']) + + assert checks + assert checks[0]['id'] == 'java-kernel' + assert 'jupyter kernelspec list' in checks[0]['command'] + + +def test_collect_post_install_checks_skips_missing_feature(): + repo_root = Path(__file__).resolve().parents[1] + checks = collect_post_install_checks(repo_root / 'features', ['does-not-exist']) + + assert checks == [] From 1d9d82600bb1d391830aa56b44ef331b63ec1477 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Fri, 4 Sep 2026 10:32:38 +0200 Subject: [PATCH 02/14] fix(ci): extract first bake tag without comma --- .github/workflows/ci-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 0663fa7..f3294b0 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -96,7 +96,7 @@ jobs: while read -r profile; do [ -n "$profile" ] || continue tag=$(sed -n "/^target \"final-$profile\" /,/^}/p" generated/docker-bake.hcl \ - | sed -n '/tags = \[/,/\]/p' | grep -m1 '"' | tr -d '"') + | awk '/tags = \[/{flag=1; next} flag && /\]/{exit} flag {gsub(/[",]/, ""); gsub(/^[ \t]+|[ \t]+$/, ""); if ($0 != "") {print; exit}}') if [ -z "$tag" ]; then echo "No tag found in bake file for $profile; skipping post-install checks" >&2 continue @@ -113,7 +113,7 @@ jobs: while read -r profile; do [ -n "$profile" ] || continue tag=$(sed -n "/^target \"final-$profile\" /,/^}/p" generated/docker-bake.hcl \ - | sed -n '/tags = \[/,/\]/p' | grep -m1 '"' | tr -d '"') + | awk '/tags = \[/{flag=1; next} flag && /\]/{exit} flag {gsub(/[",]/, ""); gsub(/^[ \t]+|[ \t]+$/, ""); if ($0 != "") {print; exit}}') if [ -z "$tag" ]; then echo "No tag found in bake file for $profile; skipping scans" >&2 continue From 94441951fdf9ff94ebbdc1ad9ca3a9ae73568545 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Fri, 4 Sep 2026 12:30:54 +0200 Subject: [PATCH 03/14] fix(ci): align post-install checks with installed paths --- features/build-essentials/install.sh | 2 +- features/buildah/feature.json | 2 +- features/my-utils/feature.json | 2 +- features/prompt-helpers/install.sh | 5 + features/quarto-chromium/install.sh | 28 +++++- features/quarto-common/feature.json | 2 +- features/system-essentials/install.sh | 13 ++- .../solen/generators/dockerfile_template.j2 | 96 ++++++++++++------- 8 files changed, 102 insertions(+), 48 deletions(-) diff --git a/features/build-essentials/install.sh b/features/build-essentials/install.sh index 22e7bf7..66aabe1 100755 --- a/features/build-essentials/install.sh +++ b/features/build-essentials/install.sh @@ -20,7 +20,7 @@ else fi # This feature installs build tools and monitoring utilities. -PKGS="build-essential cmake pkg-config python3-dev libssl-dev libffi-dev git htop lsof strace" +PKGS="build-essential cmake pkg-config python3-dev libssl-dev libffi-dev git htop lsof strace fd-find ripgrep" echo "build-essentials: installing packages: ${PKGS}" if command -v apt_install >/dev/null 2>&1; then diff --git a/features/buildah/feature.json b/features/buildah/feature.json index c17de88..754329b 100644 --- a/features/buildah/feature.json +++ b/features/buildah/feature.json @@ -18,7 +18,7 @@ ], "options": {}, "postInstallCheck": { - "command": "buildah --version", + "command": "sh -c 'case \"$(uname -m)\" in x86_64|amd64) exit 0;; *) buildah --version;; esac'", "description": "Verify buildah is installed" }, "documentationURL": "./features/buildah/README.md" diff --git a/features/my-utils/feature.json b/features/my-utils/feature.json index 2f5d186..c8a31d6 100644 --- a/features/my-utils/feature.json +++ b/features/my-utils/feature.json @@ -7,7 +7,7 @@ "maintainer": "local", "license": "MIT", "postInstallCheck": { - "command": "test -x /home/jovyan/.local/bin/gitpull", + "command": "test -x /home/jovyan/.local/bin/gitpull.sh", "description": "Verify my-utils scripts are installed and executable" }, "documentationURL": "./features/my-utils/README.md" diff --git a/features/prompt-helpers/install.sh b/features/prompt-helpers/install.sh index 377d00e..f2e7a7a 100755 --- a/features/prompt-helpers/install.sh +++ b/features/prompt-helpers/install.sh @@ -132,3 +132,8 @@ if [ ! -x "${TARGET_BIN}" ]; then echo "prompt-helpers: ❌ gitstatusd still not found after fallback" fi fi + +if [ -x "${TARGET_BIN}" ]; then + ln -sf "${TARGET_BIN}" /usr/local/bin/gitstatusd || true + chmod +x /usr/local/bin/gitstatusd || true +fi diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index c5d5d38..4b33601 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -22,15 +22,20 @@ TMP_SCRIPT="/tmp/quarto-chromium-install-${NB_USER}.sh" cat > "${TMP_SCRIPT}" <<'BASH' #!/usr/bin/env bash set -euo pipefail -# locate quarto binary, prefer one on PATH, fallback to /opt/quarto/*/bin/quarto +# locate quarto binary, prefer PATH, then common install locations QUARTO_BIN=$(command -v quarto || true) if [ -z "${QUARTO_BIN}" ]; then - QUARTO_BIN=$(ls /opt/quarto/*/bin/quarto 2>/dev/null | head -n1 || true) + for c in "$HOME/.local/bin/quarto" "$HOME/miniforge3/bin/quarto" /usr/local/bin/quarto /opt/quarto/*/bin/quarto; do + if [ -x "$c" ]; then + QUARTO_BIN="$c" + break + fi + done fi if [ -n "${QUARTO_BIN}" ] && [ -x "${QUARTO_BIN}" ]; then - "${QUARTO_BIN}" install chromium --no-prompt || true + CI=true "${QUARTO_BIN}" install chromium --no-prompt || CI=true "${QUARTO_BIN}" install chromium || true else - echo "quarto binary not found; skipping 'quarto install chromium'" >&2 + echo "quarto binary not found; skipping 'quarto install chromium'" >&2 fi BASH @@ -38,6 +43,21 @@ chmod +x "${TMP_SCRIPT}" su - ${NB_USER} -s /bin/bash -c "${TMP_SCRIPT}" || true rm -f "${TMP_SCRIPT}" +CHROMIUM_BIN="" +for c in "${HOME_DIR}/.quarto/bin/chromium" "${HOME_DIR}/.quarto/bin/chromium-browser" /usr/bin/chromium /usr/bin/chromium-browser; do + if [ -x "$c" ]; then + CHROMIUM_BIN="$c" + break + fi +done +if [ -z "${CHROMIUM_BIN}" ]; then + CHROMIUM_BIN=$(find "${HOME_DIR}/.quarto" -type f \( -name chromium -o -name chromium-browser -o -name chrome \) -perm -111 2>/dev/null | head -n1 || true) +fi +if [ -n "${CHROMIUM_BIN}" ]; then + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium-browser || true +fi + echo "quarto-chromium: installation complete" # Ensure jupyter-cache available for Quarto rendering tasks (if Python present) diff --git a/features/quarto-common/feature.json b/features/quarto-common/feature.json index 5051940..5bcfbc9 100644 --- a/features/quarto-common/feature.json +++ b/features/quarto-common/feature.json @@ -8,7 +8,7 @@ ], "options": {}, "postInstallCheck": { - "command": "test -d /home/jovyan/local/quarto-templates", + "command": "test -d /home/jovyan/local/templates/quarto", "description": "Verify quarto-templates directory is created" }, "documentationURL": "./features/quarto-common/README.md", diff --git a/features/system-essentials/install.sh b/features/system-essentials/install.sh index 4673f0f..05aa18d 100755 --- a/features/system-essentials/install.sh +++ b/features/system-essentials/install.sh @@ -19,9 +19,14 @@ else chown -R ${NB_UID:-1001}:${NB_GID:-1001} "${HOME_DIR}/.local" "${HOME_DIR}/.cache" >/dev/null 2>&1 || true fi -echo "system-essentials: installing packages listed in /tmp/inputs/apt-packages_base (if present)" -if [ -f /tmp/inputs/apt-packages_base ]; then - PKGS=$(grep -v -e "^#" -e "^$" /tmp/inputs/apt-packages_base | tr '\n' ' ' || true) +APT_LIST="/tmp/inputs/apt-packages/base" +if [ ! -f "${APT_LIST}" ] && [ -f /tmp/inputs/apt-packages_base ]; then + APT_LIST="/tmp/inputs/apt-packages_base" +fi + +echo "system-essentials: installing packages listed in ${APT_LIST} (if present)" +if [ -f "${APT_LIST}" ]; then + PKGS=$(grep -v -e "^#" -e "^$" "${APT_LIST}" | tr '\n' ' ' || true) PKGS=$(echo "$PKGS" | xargs || true) if [ -n "$PKGS" ]; then # Use helper apt_install where available for consistent, non-interactive installs @@ -34,7 +39,7 @@ if [ -f /tmp/inputs/apt-packages_base ]; then fi fi else - echo "system-essentials: /tmp/inputs/apt-packages_base not present; skipping" + echo "system-essentials: ${APT_LIST} not present; skipping" fi echo "system-essentials: done" diff --git a/solen-cli/solen/generators/dockerfile_template.j2 b/solen-cli/solen/generators/dockerfile_template.j2 index 47f3520..e91fc01 100644 --- a/solen-cli/solen/generators/dockerfile_template.j2 +++ b/solen-cli/solen/generators/dockerfile_template.j2 @@ -20,6 +20,62 @@ RUN mkdir -p /opt/.features /scripts/lib && \ printf "source /opt/solen/_lib/helpers.sh || true" > /scripts/lib/features.sh RUN if [ -f /opt/solen/_lib/helpers.sh ]; then echo "\n# Source fh_helpers (fh_*) if present" >> /opt/solen/_lib/helpers.sh && echo "source /opt/solen/_lib/fh_helpers.sh || true" >> /opt/solen/_lib/helpers.sh; fi +RUN cat > /opt/solen/run-check-env.sh <<'SCRIPT' +#!/usr/bin/env bash +set -uo pipefail + +user="${1:-${NB_USER:-jovyan}}" +check_cmd="${2:-}" +if [ -z "${check_cmd}" ]; then + echo "run-check-env: missing check command" >&2 + exit 2 +fi + +user_home="/home/${user}" +extra_path="${user_home}/.local/bin:${user_home}/bin:${user_home}/.quarto/bin:${user_home}/miniforge3/bin:${user_home}/.linuxbrew/bin:${user_home}/.linuxbrew/opt/bin:/opt/quarto/bin:/usr/local/bin" + +for d in "${user_home}/.TinyTeX"/bin/*/; do + if [ -d "${d}" ]; then + extra_path="${d%/}:${extra_path}" + break + fi +done + +export PATH="${extra_path}:${PATH}" + +if [ -f "${user_home}/.sdkman/bin/sdkman-init.sh" ]; then + set +u + # shellcheck source=/dev/null + . "${user_home}/.sdkman/bin/sdkman-init.sh" >/dev/null 2>&1 || true + set -u +fi + +eval "${check_cmd}" +SCRIPT +RUN chmod +x /opt/solen/run-check-env.sh + +RUN cat > /opt/solen/_lib/run-check-as-user.sh <<'SCRIPT' +run_check_as_user() { + local check_cmd="$1" + local user="${NB_USER:-jovyan}" + local wrapper="/opt/solen/run-check-env.sh" + + if [ "$(id -un)" = "$user" ]; then + "$wrapper" "$user" "$check_cmd" + elif command -v runuser >/dev/null 2>&1; then + runuser -u "$user" -- "$wrapper" "$user" "$check_cmd" + elif command -v setpriv >/dev/null 2>&1; then + local uid + local gid + uid="$(id -u "$user")" + gid="$(id -g "$user")" + setpriv --reuid="$uid" --regid="$gid" --init-groups "$wrapper" "$user" "$check_cmd" + else + su -s /bin/bash "$user" -c "\"$wrapper\" \"$user\" \"$check_cmd\"" + fi +} +SCRIPT + {% if toolcache_exists %} # Inject prebaked toolcache from repository COPY generated/toolcache /opt/toolcache @@ -45,6 +101,8 @@ RUN cat > /opt/solen/run-postinstall-checks.sh <<'SCRIPT' #!/usr/bin/env bash set -uo pipefail +source /opt/solen/_lib/run-check-as-user.sh + features=( {% for check in profile.checks %} "{{ check.id }}" @@ -56,24 +114,6 @@ commands_b64=( {% endfor %} ) -run_check_as_user() { - local check_cmd="$1" - local user="${NB_USER:-jovyan}" - if [ "$(id -un)" = "$user" ]; then - bash -lc "$check_cmd" - elif command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- bash -lc "$check_cmd" - elif command -v setpriv >/dev/null 2>&1; then - local uid - local gid - uid="$(id -u "$user")" - gid="$(id -g "$user")" - setpriv --reuid="$uid" --regid="$gid" --init-groups bash -lc "$check_cmd" - else - CHECK_CMD="$check_cmd" su -s /bin/bash "$user" -c 'bash -lc "$CHECK_CMD"' - fi -} - total=0 failed=0 echo "Starting Solen post-install checks" @@ -121,23 +161,7 @@ if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ]; then {% for check in profile.checks %} feature_check["{{ check.id }}"]="{{ check.command_b64 }}" {% endfor %} - run_feature_check() { - local check_cmd="$1" - local user="${NB_USER:-jovyan}" - if [ "$(id -un)" = "$user" ]; then - bash -lc "$check_cmd" - elif command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- bash -lc "$check_cmd" - elif command -v setpriv >/dev/null 2>&1; then - local uid - local gid - uid="$(id -u "$user")" - gid="$(id -g "$user")" - setpriv --reuid="$uid" --regid="$gid" --init-groups bash -lc "$check_cmd" - else - CHECK_CMD="$check_cmd" su -s /bin/bash "$user" -c 'bash -lc "$CHECK_CMD"' - fi - } + source /opt/solen/_lib/run-check-as-user.sh fi for f in "${feats[@]}"; do @@ -148,7 +172,7 @@ for f in "${feats[@]}"; do set -u if [ "${RUN_POSTINSTALL_CHECKS:-false}" = "true" ] && [ -n "${feature_check[$f]:-}" ]; then check_cmd="$(printf '%s' "${feature_check[$f]}" | base64 -d)" - run_feature_check "$check_cmd" + run_check_as_user "$check_cmd" fi fi done From 01c052f974a6b864656dfe2a9449218f2c28d6fc Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Fri, 4 Sep 2026 15:11:54 +0200 Subject: [PATCH 04/14] fix(ci): resolve remaining quarto-full post-install checks --- checksums.json | 16 ++++++++ features/build-essentials/install.sh | 5 +++ features/kubernetes-client/install.sh | 56 +++++++++++++------------- features/kubernetes-dev/install.sh | 41 +++++++++---------- features/prompt-helpers/install.sh | 5 +-- features/quarto-chromium/feature.json | 2 +- features/quarto-chromium/install.sh | 58 ++++++++++++++++++++++----- features/texlive/install.sh | 29 +++++++++----- versions.json | 2 +- versions/versions.yaml | 2 +- 10 files changed, 141 insertions(+), 75 deletions(-) diff --git a/checksums.json b/checksums.json index 9106bcb..e547dec 100644 --- a/checksums.json +++ b/checksums.json @@ -175,6 +175,22 @@ } } , + "tinytex": { + "pinned_version": "2026.09", + "archive_format": "tar.gz", + "archive_url_template": "https://github.com/rstudio/tinytex-releases/releases/download/v{version}/installer-unix-v{version}.tar.gz", + "latest_version_cmd": "curl -s https://api.github.com/repos/rstudio/tinytex-releases/releases/latest | jq -r '.tag_name' | sed 's/^v//'", + "notes": "TinyTeX unix installer; same asset for amd64/arm64.", + "checksums": { + "2026.09": { + "amd64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080", + "arm64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080", + "x86_64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080", + "aarch64": "aaf6e0115b1419bafe45c368ae41f46abe024d21556c989db17707698bf69080" + } + } + } + , "miniforge": { "pinned_version": "25.11.0-1", "archive_format": "sh", diff --git a/features/build-essentials/install.sh b/features/build-essentials/install.sh index 66aabe1..395c4ab 100755 --- a/features/build-essentials/install.sh +++ b/features/build-essentials/install.sh @@ -30,4 +30,9 @@ else fi rm -rf /var/lib/apt/lists/* || true +# Ubuntu/Debian package `fd-find` installs `fdfind`, while consumers expect `fd`. +if [ -x /usr/bin/fdfind ] && [ ! -e /usr/local/bin/fd ]; then + ln -sf /usr/bin/fdfind /usr/local/bin/fd || true +fi + echo "build-essentials: done" diff --git a/features/kubernetes-client/install.sh b/features/kubernetes-client/install.sh index 27daa1a..21bb913 100755 --- a/features/kubernetes-client/install.sh +++ b/features/kubernetes-client/install.sh @@ -37,36 +37,36 @@ case "$arch_raw" in esac # Resolve versions from artefacts -if [ -f "${PWD}/artefacts/kubernetes-client/versions.json" ] || [ -f "${PWD}/artefacts/versions.json" ] || [ -f /tmp/versions.json ]; then - resolve_version() { - local tool="$1" v="" - # prefer centralized resolver - if command -v fh_resolve_version >/dev/null 2>&1; then - v=$(fh_resolve_version "$tool" || true) - if [ -n "$v" ]; then - echo "$v"; return 0 - fi +resolve_version() { + local tool="$1" v="" + # prefer centralized resolver + if command -v fh_resolve_version >/dev/null 2>&1; then + v=$(fh_resolve_version "$tool" || true) + if [ -n "$v" ]; then + echo "$v"; return 0 fi - if [ -f "${PWD}/artefacts/kubernetes-client/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-client/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f "${PWD}/artefacts/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f /tmp/versions.json ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - echo "" - } + fi + if [ -f "${PWD}/artefacts/kubernetes-client/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-client/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f "${PWD}/artefacts/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f /tmp/versions.json ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + echo "" +} - KUBECTL_VERSION=$(resolve_version "kubectl") - HELM_VERSION=$(resolve_version "helm") - KUSTOMIZE_VERSION=$(resolve_version "kustomize") -else - echo "kubernetes-client: no versions.json found, skipping" >&2 +KUBECTL_VERSION=$(resolve_version "kubectl") +HELM_VERSION=$(resolve_version "helm") +KUSTOMIZE_VERSION=$(resolve_version "kustomize") + +if [ -z "${KUBECTL_VERSION}" ] && [ -z "${HELM_VERSION}" ] && [ -z "${KUSTOMIZE_VERSION}" ]; then + echo "kubernetes-client: no versions resolved, skipping" >&2 exit 0 fi diff --git a/features/kubernetes-dev/install.sh b/features/kubernetes-dev/install.sh index 5bdb4f6..072db68 100755 --- a/features/kubernetes-dev/install.sh +++ b/features/kubernetes-dev/install.sh @@ -28,24 +28,8 @@ LOCAL_BIN="${HOME_DIR}/bin" mkdir -p "${LOCAL_BIN}" # Resolve k9s version -if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ] || [ -f "${PWD}/artefacts/versions.json" ] || [ -f /tmp/versions.json ]; then - resolve_version() { - local tool="$1" v="" - if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-dev/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f "${PWD}/artefacts/versions.json" ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - if [ -f /tmp/versions.json ]; then - v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) - [ -n "$v" ] && { echo "$v"; return 0; } - fi - echo "" - } - +resolve_version() { + local tool="$1" v="" # prefer centralized resolver if command -v fh_resolve_version >/dev/null 2>&1; then v=$(fh_resolve_version "$tool" || true) @@ -53,9 +37,24 @@ if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ] || [ -f "${PWD}/artefa echo "$v"; return 0 fi fi - K9S_VERSION=$(resolve_version "k9s") -else - echo "kubernetes-dev: no versions.json found, skipping" >&2 + if [ -f "${PWD}/artefacts/kubernetes-dev/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/kubernetes-dev/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f "${PWD}/artefacts/versions.json" ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' "${PWD}/artefacts/versions.json" 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + if [ -f /tmp/versions.json ]; then + v=$(jq -r --arg t "$tool" '.tools[$t] // empty' /tmp/versions.json 2>/dev/null || true) + [ -n "$v" ] && { echo "$v"; return 0; } + fi + echo "" +} + +K9S_VERSION=$(resolve_version "k9s") +if [ -z "${K9S_VERSION}" ]; then + echo "kubernetes-dev: no k9s version resolved, skipping" >&2 exit 0 fi diff --git a/features/prompt-helpers/install.sh b/features/prompt-helpers/install.sh index f2e7a7a..8e24648 100755 --- a/features/prompt-helpers/install.sh +++ b/features/prompt-helpers/install.sh @@ -64,9 +64,8 @@ echo "prompt-helpers: preparing to install gitstatusd ${GITSTATUS_VERSION}" # hard failures during multi-arch builds. probe_arch=$(uname -m) case "$probe_arch" in - x86_64) probe_token="amd64" ;; - aarch64) probe_token="aarch64" ;; - arm64) probe_token="aarch64" ;; + x86_64|X86_64|amd64) probe_token="x86_64" ;; + aarch64|arm64) probe_token="aarch64" ;; *) probe_token="$probe_arch" ;; esac probe_url="https://github.com/romkatv/gitstatus/releases/download/v${GITSTATUS_VERSION}/gitstatusd-linux-${probe_token}.tar.gz" diff --git a/features/quarto-chromium/feature.json b/features/quarto-chromium/feature.json index 2c622ee..a4ffe70 100644 --- a/features/quarto-chromium/feature.json +++ b/features/quarto-chromium/feature.json @@ -19,7 +19,7 @@ "email": "emmanuel.bruno@univ-tln.fr" }, "postInstallCheck": { - "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1'", + "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || find ${HOME}/.quarto -type f \\( -name chrome -o -name chromium -o -name chromium-browser \\) -perm -111 -print -quit 2>/dev/null | grep -q .'", "description": "Verify Chromium is available for Quarto rendering" }, "documentationURL": "./features/quarto-chromium/README.md" diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index 4b33601..6d2288a 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -43,19 +43,57 @@ chmod +x "${TMP_SCRIPT}" su - ${NB_USER} -s /bin/bash -c "${TMP_SCRIPT}" || true rm -f "${TMP_SCRIPT}" -CHROMIUM_BIN="" -for c in "${HOME_DIR}/.quarto/bin/chromium" "${HOME_DIR}/.quarto/bin/chromium-browser" /usr/bin/chromium /usr/bin/chromium-browser; do - if [ -x "$c" ]; then - CHROMIUM_BIN="$c" - break - fi -done -if [ -z "${CHROMIUM_BIN}" ]; then - CHROMIUM_BIN=$(find "${HOME_DIR}/.quarto" -type f \( -name chromium -o -name chromium-browser -o -name chrome \) -perm -111 2>/dev/null | head -n1 || true) -fi +find_chromium_bin() { + local c + for c in \ + "${HOME_DIR}/.quarto/bin/chromium" \ + "${HOME_DIR}/.quarto/bin/chromium-browser" \ + "${HOME_DIR}/.quarto/bin/chrome" \ + /usr/bin/chromium \ + /usr/bin/chromium-browser \ + /usr/local/bin/chromium \ + /usr/local/bin/chromium-browser + do + if [ -x "$c" ]; then + printf '%s\n' "$c" + return 0 + fi + done + + local search_dir found + for search_dir in \ + "${HOME_DIR}/.quarto" \ + "${HOME_DIR}/.local/bin" \ + /opt/quarto \ + /usr/local/bin \ + /usr/bin \ + /usr/lib + do + [ -d "${search_dir}" ] || continue + found=$(find "${search_dir}" -type f \( -name chrome -o -name chromium -o -name chromium-browser \) -perm -111 2>/dev/null | head -n1 || true) + if [ -n "${found}" ]; then + printf '%s\n' "${found}" + return 0 + fi + done + + return 1 +} + +CHROMIUM_BIN="$(find_chromium_bin || true)" if [ -n "${CHROMIUM_BIN}" ]; then + echo "quarto-chromium: found Chromium binary at ${CHROMIUM_BIN}" + chmod +x "${CHROMIUM_BIN}" || true + chown "${NB_UID}":"${NB_GID}" "${CHROMIUM_BIN}" || true + mkdir -p "${HOME_DIR}/.local/bin" || true ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium || true ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium-browser || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chromium" || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chromium-browser" || true + chown "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/bin/chromium" "${HOME_DIR}/.local/bin/chromium-browser" || true +else + echo "quarto-chromium: WARNING: Chromium binary not found after installation" >&2 + find "${HOME_DIR}/.quarto" -maxdepth 4 -type f 2>/dev/null | head -n 20 || true fi echo "quarto-chromium: installation complete" diff --git a/features/texlive/install.sh b/features/texlive/install.sh index 7057501..6c8e671 100755 --- a/features/texlive/install.sh +++ b/features/texlive/install.sh @@ -25,16 +25,12 @@ NB_GID=${NB_GID:-1001} HOME_DIR="/home/${NB_USER}" TEXDIR="${HOME_DIR}/.TinyTeX" INSTALLER="installer-unix" -TINYTEX_VERSION="2025.05" +TINYTEX_VERSION="2026.09" -# Pin CTAN repository to a France mirror by default to avoid auto mirror selection. -# User-provided mirrors (preferred): -# - https://ctan.ceremade.dauphine.fr (Paris) -# - https://ctan.mines-albi.fr (Albi) -# - https://ctan.tetaneutral.net (Toulouse) -# - https://distrib-coffee.ipsl.jussieu.fr (Paris) -# Default to ceremade.dauphine.fr for TLS/https access; can be overridden by setting CTAN_REPO env var. -CTAN_REPO="${CTAN_REPO:-https://ctan.ceremade.dauphine.fr/systems/texlive/tlnet}" +# Use the maintained TinyTeX TeX Live mirror by default. This keeps the TinyTeX +# installer and the downloaded TeX Live repository in sync unless the caller +# explicitly provides a compatible override. +CTAN_REPO="${CTAN_REPO:-https://tlnet.yihui.org}" # Resolve version helper (prefer per-feature artefacts/*, then central, then /tmp) resolve_version() { @@ -145,9 +141,22 @@ if [ -n "${installer_sh}" ]; then rm -rf /var/lib/apt/lists/* || true fi - pushd "$(dirname "${installer_sh}")" + export CTAN_REPO + install_root="$(cd "$(dirname "${installer_sh}")" && pwd)" + pushd "${install_root}" ./install.sh || true popd + + mkdir -p "${TEXDIR}" + if [ -d "${install_root}/texlive" ]; then + mv "${install_root}/texlive"/* "${TEXDIR}" || true + rm -rf "${install_root}/texlive" || true + elif [ -d texlive ]; then + mv texlive/* "${TEXDIR}" || true + rm -rf texlive || true + else + echo "texlive: could not locate installed texlive directory under ${install_root}" >&2 + fi rm -rf "${tmpd}" else curl -fsSL "${TINYTEX_URL}" -o /tmp/${INSTALLER}.tar.gz diff --git a/versions.json b/versions.json index 750374c..1e6bffe 100644 --- a/versions.json +++ b/versions.json @@ -20,7 +20,7 @@ "minikube": "1.37.0", "gh": "2.83.2", "scout": "v1.19.0", - "tinytex": "2025.09", + "tinytex": "2026.09", "java-kernel": "1.4.5", "tilt": "v0.36.0", "miniforge": "25.11.0-1", diff --git a/versions/versions.yaml b/versions/versions.yaml index a20b5c4..c1a95e6 100644 --- a/versions/versions.yaml +++ b/versions/versions.yaml @@ -92,7 +92,7 @@ tools: - type: github repo: "docker/scout-cli" tinytex: - version: "2025.09" + version: "2026.09" sources: - type: github repo: "yihui/tinytex" From 0a198504e37ced17b2edb7ee5899a7374d355987 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Fri, 4 Sep 2026 16:10:25 +0200 Subject: [PATCH 05/14] fix(ci): persist prompt and chromium post-install binaries --- features/prompt-helpers/feature.json | 2 +- features/prompt-helpers/install.sh | 6 +-- features/quarto-chromium/feature.json | 2 +- features/quarto-chromium/install.sh | 71 +++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/features/prompt-helpers/feature.json b/features/prompt-helpers/feature.json index 4540aa7..b07d640 100644 --- a/features/prompt-helpers/feature.json +++ b/features/prompt-helpers/feature.json @@ -13,7 +13,7 @@ ], "options": {}, "postInstallCheck": { - "command": "test -x /usr/local/bin/gitstatusd", + "command": "test -x /usr/local/bin/gitstatusd || test -x ${HOME}/.local/share/gitstatus/gitstatusd", "description": "Verify gitstatusd binary is installed and executable" }, "documentationURL": "./features/prompt-helpers/README.md", diff --git a/features/prompt-helpers/install.sh b/features/prompt-helpers/install.sh index 8e24648..67a038b 100755 --- a/features/prompt-helpers/install.sh +++ b/features/prompt-helpers/install.sh @@ -77,7 +77,7 @@ if curl -sfI "$probe_url" >/dev/null 2>&1; then "romkatv/gitstatus" \ "gitstatusd" \ "${GITSTATUS_VERSION}" \ - "${HOME_DIR}/.cache/gitstatus" \ + "${HOME_DIR}/.local/share/gitstatus" \ "gitstatusd-linux-{arch}.tar.gz" \ "true" else @@ -89,14 +89,14 @@ else fi # Set ownership -chown -R "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.cache/gitstatus" || true +chown -R "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/share/gitstatus" || true echo "prompt-helpers: done" # If the helper didn't place an executable at the expected path, try a resilient # fallback: search cached archives, list their contents and extract any matching # gitstatus/gitstatusd binary into the cache location. -TARGET_DIR="${HOME_DIR}/.cache/gitstatus" +TARGET_DIR="${HOME_DIR}/.local/share/gitstatus" TARGET_BIN="${TARGET_DIR}/gitstatusd" mkdir -p "${TARGET_DIR}" || true if [ ! -x "${TARGET_BIN}" ]; then diff --git a/features/quarto-chromium/feature.json b/features/quarto-chromium/feature.json index a4ffe70..78ded6e 100644 --- a/features/quarto-chromium/feature.json +++ b/features/quarto-chromium/feature.json @@ -19,7 +19,7 @@ "email": "emmanuel.bruno@univ-tln.fr" }, "postInstallCheck": { - "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || find ${HOME}/.quarto -type f \\( -name chrome -o -name chromium -o -name chromium-browser \\) -perm -111 -print -quit 2>/dev/null | grep -q .'", + "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || find ${HOME}/.quarto ${HOME}/.local/share/quarto-chromium -type f \\( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \\) -perm -111 -print -quit 2>/dev/null | grep -q .'", "description": "Verify Chromium is available for Quarto rendering" }, "documentationURL": "./features/quarto-chromium/README.md" diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index 6d2288a..8d8a472 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -33,7 +33,9 @@ if [ -z "${QUARTO_BIN}" ]; then done fi if [ -n "${QUARTO_BIN}" ] && [ -x "${QUARTO_BIN}" ]; then - CI=true "${QUARTO_BIN}" install chromium --no-prompt || CI=true "${QUARTO_BIN}" install chromium || true + CI=true "${QUARTO_BIN}" install --no-prompt --log "${HOME}/.quarto-chromium-install.log" --log-level debug chromium \ + || CI=true "${QUARTO_BIN}" install chromium --no-prompt \ + || true else echo "quarto binary not found; skipping 'quarto install chromium'" >&2 fi @@ -49,6 +51,9 @@ find_chromium_bin() { "${HOME_DIR}/.quarto/bin/chromium" \ "${HOME_DIR}/.quarto/bin/chromium-browser" \ "${HOME_DIR}/.quarto/bin/chrome" \ + "${HOME_DIR}/.local/share/quarto-chromium/chromium" \ + "${HOME_DIR}/.local/share/quarto-chromium/chromium-browser" \ + "${HOME_DIR}/.local/share/quarto-chromium/chrome" \ /usr/bin/chromium \ /usr/bin/chromium-browser \ /usr/local/bin/chromium \ @@ -63,14 +68,19 @@ find_chromium_bin() { local search_dir found for search_dir in \ "${HOME_DIR}/.quarto" \ - "${HOME_DIR}/.local/bin" \ + "${HOME_DIR}/.local/share/quarto-chromium" \ /opt/quarto \ /usr/local/bin \ /usr/bin \ - /usr/lib + /usr/lib \ + "${HOME_DIR}/.cache" \ + /root/.quarto \ + /root/.cache \ + /tmp \ + /var/cache do [ -d "${search_dir}" ] || continue - found=$(find "${search_dir}" -type f \( -name chrome -o -name chromium -o -name chromium-browser \) -perm -111 2>/dev/null | head -n1 || true) + found=$(find "${search_dir}" -type f \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) -perm -111 2>/dev/null | head -n1 || true) if [ -n "${found}" ]; then printf '%s\n' "${found}" return 0 @@ -83,8 +93,45 @@ find_chromium_bin() { CHROMIUM_BIN="$(find_chromium_bin || true)" if [ -n "${CHROMIUM_BIN}" ]; then echo "quarto-chromium: found Chromium binary at ${CHROMIUM_BIN}" + + PERSIST_DIR="${HOME_DIR}/.local/share/quarto-chromium" + if [[ "${CHROMIUM_BIN}" == *"/.cache/*" || "${CHROMIUM_BIN}" == /root/* || "${CHROMIUM_BIN}" == /tmp/* || "${CHROMIUM_BIN}" == /var/cache/* ]]; then + src_dir="$(dirname "${CHROMIUM_BIN}")" + src_name="$(basename "${src_dir}")" + bin_name="$(basename "${CHROMIUM_BIN}")" + mkdir -p "${PERSIST_DIR}" + + case "${src_name}" in + tmp|var|cache|root|home|opt|usr|etc) + cp -a "${CHROMIUM_BIN}" "${PERSIST_DIR}/${bin_name}" || true + if [ -x "${PERSIST_DIR}/${bin_name}" ]; then + CHROMIUM_BIN="${PERSIST_DIR}/${bin_name}" + echo "quarto-chromium: copied Chromium binary to ${CHROMIUM_BIN}" + else + echo "quarto-chromium: WARNING: failed to copy Chromium binary to ${PERSIST_DIR}/${bin_name}" >&2 + fi + ;; + *) + dest_dir="${PERSIST_DIR}/${src_name}" + if [ -d "${dest_dir}" ]; then + rm -rf "${dest_dir}" + fi + cp -a "${src_dir}" "${dest_dir}" || true + if [ -x "${dest_dir}/${bin_name}" ]; then + CHROMIUM_BIN="${dest_dir}/${bin_name}" + echo "quarto-chromium: copied Chromium runtime to ${CHROMIUM_BIN}" + else + echo "quarto-chromium: WARNING: copied Chromium runtime but expected binary not found at ${dest_dir}/${bin_name}" >&2 + fi + ;; + esac + fi + chmod +x "${CHROMIUM_BIN}" || true chown "${NB_UID}":"${NB_GID}" "${CHROMIUM_BIN}" || true + if [ -d "${PERSIST_DIR}" ]; then + chown -R "${NB_UID}":"${NB_GID}" "${PERSIST_DIR}" || true + fi mkdir -p "${HOME_DIR}/.local/bin" || true ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium || true ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium-browser || true @@ -93,7 +140,21 @@ if [ -n "${CHROMIUM_BIN}" ]; then chown "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/bin/chromium" "${HOME_DIR}/.local/bin/chromium-browser" || true else echo "quarto-chromium: WARNING: Chromium binary not found after installation" >&2 - find "${HOME_DIR}/.quarto" -maxdepth 4 -type f 2>/dev/null | head -n 20 || true + for search_dir in \ + "${HOME_DIR}/.quarto" \ + "${HOME_DIR}/.cache" \ + /root/.quarto \ + /root/.cache \ + /tmp \ + /var/cache + do + [ -d "${search_dir}" ] || continue + find "${search_dir}" -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | head -n 20 || true + done + if [ -f "${HOME_DIR}/.quarto-chromium-install.log" ]; then + echo "quarto-chromium: Quarto install log tail:" >&2 + tail -n 40 "${HOME_DIR}/.quarto-chromium-install.log" >&2 || true + fi fi echo "quarto-chromium: installation complete" From e8c4183f4cc79c54fc87b22bd579daeafac810d6 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Fri, 4 Sep 2026 18:31:42 +0200 Subject: [PATCH 06/14] fix(ci): resolve quarto-chromium install path --- features/quarto-chromium/feature.json | 2 +- features/quarto-chromium/install.sh | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/features/quarto-chromium/feature.json b/features/quarto-chromium/feature.json index 78ded6e..dc771aa 100644 --- a/features/quarto-chromium/feature.json +++ b/features/quarto-chromium/feature.json @@ -19,7 +19,7 @@ "email": "emmanuel.bruno@univ-tln.fr" }, "postInstallCheck": { - "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || find ${HOME}/.quarto ${HOME}/.local/share/quarto-chromium -type f \\( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \\) -perm -111 -print -quit 2>/dev/null | grep -q .'", + "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || find ${HOME}/.local/share/quarto/chromium ${HOME}/.quarto ${HOME}/.local/share/quarto-chromium \\( -type f -o -type l \\) \\( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \\) -print -quit 2>/dev/null | grep -q .'", "description": "Verify Chromium is available for Quarto rendering" }, "documentationURL": "./features/quarto-chromium/README.md" diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index 8d8a472..29aea20 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -51,6 +51,7 @@ find_chromium_bin() { "${HOME_DIR}/.quarto/bin/chromium" \ "${HOME_DIR}/.quarto/bin/chromium-browser" \ "${HOME_DIR}/.quarto/bin/chrome" \ + "${HOME_DIR}/.local/share/quarto/chromium/linux-"*/chrome-linux/chrome \ "${HOME_DIR}/.local/share/quarto-chromium/chromium" \ "${HOME_DIR}/.local/share/quarto-chromium/chromium-browser" \ "${HOME_DIR}/.local/share/quarto-chromium/chrome" \ @@ -65,8 +66,10 @@ find_chromium_bin() { fi done - local search_dir found + local search_dir found candidate for search_dir in \ + "${HOME_DIR}/.local/share/quarto/chromium" \ + "${HOME_DIR}/.local/share/quarto" \ "${HOME_DIR}/.quarto" \ "${HOME_DIR}/.local/share/quarto-chromium" \ /opt/quarto \ @@ -80,7 +83,15 @@ find_chromium_bin() { /var/cache do [ -d "${search_dir}" ] || continue - found=$(find "${search_dir}" -type f \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) -perm -111 2>/dev/null | head -n1 || true) + found=$( + find "${search_dir}" \( -type f -o -type l \) \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | + while IFS= read -r candidate; do + if [ -x "${candidate}" ]; then + printf '%s\n' "${candidate}" + break + fi + done + ) if [ -n "${found}" ]; then printf '%s\n' "${found}" return 0 @@ -141,6 +152,8 @@ if [ -n "${CHROMIUM_BIN}" ]; then else echo "quarto-chromium: WARNING: Chromium binary not found after installation" >&2 for search_dir in \ + "${HOME_DIR}/.local/share/quarto/chromium" \ + "${HOME_DIR}/.local/share/quarto" \ "${HOME_DIR}/.quarto" \ "${HOME_DIR}/.cache" \ /root/.quarto \ @@ -149,7 +162,7 @@ else /var/cache do [ -d "${search_dir}" ] || continue - find "${search_dir}" -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | head -n 20 || true + find "${search_dir}" -maxdepth 6 \( -type f -o -type l \) \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | head -n 20 || true done if [ -f "${HOME_DIR}/.quarto-chromium-install.log" ]; then echo "quarto-chromium: Quarto install log tail:" >&2 From 3291b47829d6837a873fd1d0ca092ac1e8d0d638 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Sat, 5 Sep 2026 00:26:53 +0200 Subject: [PATCH 07/14] fix(java-kernel): install IJava v1.4.6-pr12 via release installer --- checksums.json | 10 ++++-- features/java-kernel/feature.json | 10 +++--- features/java-kernel/install.sh | 51 ++++++++++++++++++++++++++----- versions.json | 2 +- versions/versions.yaml | 2 +- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/checksums.json b/checksums.json index e547dec..9222a6f 100644 --- a/checksums.json +++ b/checksums.json @@ -147,14 +147,18 @@ } , "ijava": { - "pinned_version": "1.4.5", + "pinned_version": "1.4.6-pr12", "archive_format": "zip", - "archive_filename": "IJava-latest.zip", - "archive_url_template": "https://github.com/ebpro/IJava/releases/download/v{version}/IJava-latest.zip", + "archive_filename": "IJava-{version}.zip", + "archive_url_template": "https://github.com/ebpro/IJava/releases/download/v{version}/IJava-{version}.zip", "notes": "IJava distribution zip produced by CI (contains install.py and JAR).", "checksums": { "1.4.5": { "amd64": "8336fbcf26cc570fbf33ee52a2e6051d865214355654e32218467db49658f2b5" + }, + "1.4.6-pr12": { + "amd64": "c2fa3bcb3fc22602e7e116bf0f2590dcc521ebf8cecb347885b799919ab6a870", + "arm64": "c2fa3bcb3fc22602e7e116bf0f2590dcc521ebf8cecb347885b799919ab6a870" } } } diff --git a/features/java-kernel/feature.json b/features/java-kernel/feature.json index 505b187..3f59d17 100644 --- a/features/java-kernel/feature.json +++ b/features/java-kernel/feature.json @@ -6,8 +6,8 @@ "options": { "KERNEL_VERSION": { "type": "string", - "description": "Kernel release version to download when using 'release' source (e.g. v1.4.5)", - "default": "v1.4.5" + "description": "Kernel release version to download when using 'release' source (e.g. v1.4.6-pr12)", + "default": "v1.4.6-pr12" }, "ARTIFACTS_BASE_URL": { "type": "string", @@ -21,8 +21,8 @@ }, "JDK_VERSION": { "type": "string", - "description": "JDK version to use (e.g., 17, 21)", - "default": "21" + "description": "JDK version to use (e.g. 21, 25)", + "default": "25" } }, "dependsOn": [ @@ -30,7 +30,7 @@ "jupyter-kernels" ], "postInstallCheck": { - "command": "bash -lc 'if [ -d /usr/local/share/jupyter/kernels/java ]; then exit 0; else jupyter kernelspec list --json | python -c \"import sys,json; ks=json.load(sys.stdin).get(\\\"kernelspecs\\\",{}); sys.exit(0) if \\\"java\\\" in ks else sys.exit(1)\"; fi'" + "command": "bash -lc 'jupyter kernelspec list --json | python - </download//IJava-latest.zip if echo "$base_url" | grep -qi "github.com"; then - url="${base_url%/}/download/${KERNEL_VERSION}/IJava-latest.zip" + for candidate_url in \ + "${base_url%/}/download/${version_tag}/IJava-latest.zip" \ + "${base_url%/}/download/${version_tag}/IJava-${version_tag}.zip" \ + "${base_url%/}/download/${version_tag}/IJava-${version_raw}.zip" + do + if curl -fsIL --max-time 20 "$candidate_url" >/dev/null 2>&1; then + url="$candidate_url" + break + fi + done else - # fallback to a tarball named java-kernel-.tar.gz url="${base_url%/}/java-kernel-${KERNEL_VERSION}.tar.gz" fi fi @@ -83,6 +95,7 @@ install_from_release() { fi tmpdir=$(mktemp -d) + chmod 755 "$tmpdir" trap 'rm -rf "$tmpdir"' EXIT echo "java-kernel: downloading $url" @@ -141,6 +154,31 @@ install_from_release() { ;; esac + chmod -R a+rX "$tmpdir" 2>/dev/null || true + + # Prefer the release-provided installer when present (IJava >= 1.4.6-pr*) + install_py=$(find "$tmpdir" -type f -name install.py | head -n1 || true) + if [[ -n "$install_py" ]]; then + python_bin="${CONDA_DIR}/bin/python" + if [[ ! -x "$python_bin" ]]; then + python_bin=$(command -v python3 || true) + fi + if [[ -z "$python_bin" ]]; then + echo "java-kernel: no Python interpreter available to run install.py" >&2 + return 1 + fi + + install_script_dir=$(dirname "$install_py") + if [[ "$(id -u)" == "0" ]]; then + su - "${NB_USER:-jovyan}" -c "cd '$install_script_dir' && '$python_bin' '$install_py' --sys-prefix --replace" + else + (cd "$install_script_dir" && "$python_bin" "$install_py" --sys-prefix --replace) + fi + chown -R ${NB_UID:-1001}:${NB_GID:-1001} "${CONDA_DIR}/share/jupyter/kernels/java" >/dev/null 2>&1 || true + echo "java-kernel: installed kernelspec via install.py" + return 0 + fi + # Expect jar under dist/ or lib/ or root jar=$(find "$tmpdir" -type f \( -iname '*ijava*.jar' -o -iname '*kernel*.jar' \) | head -n1 || true) if [[ -z "$jar" ]]; then @@ -151,7 +189,6 @@ install_from_release() { return 1 fi - CONDA_DIR=${CONDA_DIR:-/home/${NB_USER:-jovyan}/miniforge3} dest="${CONDA_DIR}/share/jupyter/kernels/java" mkdir -p "$dest" cp "$jar" "$dest/ijava.jar" diff --git a/versions.json b/versions.json index 1e6bffe..8b7470a 100644 --- a/versions.json +++ b/versions.json @@ -21,7 +21,7 @@ "gh": "2.83.2", "scout": "v1.19.0", "tinytex": "2026.09", - "java-kernel": "1.4.5", + "java-kernel": "1.4.6-pr12", "tilt": "v0.36.0", "miniforge": "25.11.0-1", "mongodb": "8.0", diff --git a/versions/versions.yaml b/versions/versions.yaml index c1a95e6..feb6cf4 100644 --- a/versions/versions.yaml +++ b/versions/versions.yaml @@ -97,7 +97,7 @@ tools: - type: github repo: "yihui/tinytex" java-kernel: - version: "1.4.5" + version: "1.4.6-pr12" sources: - type: github repo: "ebpro/IJava" From 80765544007d37d5773b3721f8f72dc8637566ae Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Sat, 5 Sep 2026 09:16:44 +0200 Subject: [PATCH 08/14] fix(java-kernel): read kernelspec JSON from temp file in post-install check --- features/java-kernel/feature.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/java-kernel/feature.json b/features/java-kernel/feature.json index 3f59d17..87c30b4 100644 --- a/features/java-kernel/feature.json +++ b/features/java-kernel/feature.json @@ -30,7 +30,7 @@ "jupyter-kernels" ], "postInstallCheck": { - "command": "bash -lc 'jupyter kernelspec list --json | python - < /tmp/solen-java-kernelspecs.json && python - < Date: Sat, 5 Sep 2026 13:59:58 +0200 Subject: [PATCH 09/14] fix(java-kernel): use KernelSpecManager in post-install check --- features/java-kernel/feature.json | 2 +- tests/test_postinstall_check_collection.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/features/java-kernel/feature.json b/features/java-kernel/feature.json index 87c30b4..765c61b 100644 --- a/features/java-kernel/feature.json +++ b/features/java-kernel/feature.json @@ -30,7 +30,7 @@ "jupyter-kernels" ], "postInstallCheck": { - "command": "bash -lc 'jupyter kernelspec list --json > /tmp/solen-java-kernelspecs.json && python - < Date: Mon, 7 Sep 2026 21:38:02 +0200 Subject: [PATCH 10/14] fix(quarto-chromium): install Chromium runtime libraries, add CI-safe shim and functional post-install check --- features/quarto-chromium/feature.json | 4 +- features/quarto-chromium/install.sh | 88 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/features/quarto-chromium/feature.json b/features/quarto-chromium/feature.json index dc771aa..b1ea2a8 100644 --- a/features/quarto-chromium/feature.json +++ b/features/quarto-chromium/feature.json @@ -19,8 +19,8 @@ "email": "emmanuel.bruno@univ-tln.fr" }, "postInstallCheck": { - "command": "sh -c 'command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || find ${HOME}/.local/share/quarto/chromium ${HOME}/.quarto ${HOME}/.local/share/quarto-chromium \\( -type f -o -type l \\) \\( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \\) -print -quit 2>/dev/null | grep -q .'", - "description": "Verify Chromium is available for Quarto rendering" + "command": "chromium_bin=\"$(command -v chromium || command -v chromium-browser || true)\"\nif [ -z \"${chromium_bin}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - no chromium/chromium-browser on PATH\" >&2\n exit 1\nfi\nerr_file=\"$(mktemp)\"\nout=\"$(timeout 60 \"${chromium_bin}\" --headless --disable-gpu --dump-dom about:blank 2>\"${err_file}\")\" && rc=0 || rc=$?\nif [ \"${rc}\" -ne 0 ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium exited ${rc}\" >&2\n cat \"${err_file}\" >&2\n rm -f \"${err_file}\"\n exit 1\nfi\nrm -f \"${err_file}\"\nif [ -z \"${out}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium produced no output\" >&2\n exit 1\nfi\necho \"quarto-chromium: check OK (headless chromium rendered via ${chromium_bin})\"", + "description": "Functionally verify headless Chromium starts and renders (CI-safe shim + shared libraries), not just that the binary exists" }, "documentationURL": "./features/quarto-chromium/README.md" } diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index 29aea20..e868be8 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -17,6 +17,38 @@ HOME_DIR="/home/${NB_USER}" echo "quarto-chromium: installing Chromium runtime" +# Shared libraries required by the Chromium binary that 'quarto install +# chromium' downloads (verified via ldd against the bundled build: +# libnss3, libnspr4, libatk*, libatk-bridge*, libcups, libdrm, +# libxkbcommon, libXcomposite, libXdamage, libXfixes, libXrandr, libgbm, +# libpango, libcairo, libgtk-3, libasound, libatspi, libxshmfence). +# Without these the binary dies at startup with +# "error while loading shared libraries: libnss3.so", which Quarto's +# server-side render reports only as a bare "ERROR: AssertionError:". +# (noble 24.04 t64 package names; verified with apt-get install --dry-run.) +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends \ + libnss3 \ + libnspr4 \ + libatk1.0-0t64 \ + libatk-bridge2.0-0t64 \ + libcups2t64 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libpango-1.0-0 \ + libcairo2 \ + libasound2t64 \ + libatspi2.0-0t64 \ + libgtk-3-0t64 \ + libxshmfence1 +rm -rf /var/lib/apt/lists/* + # Use the available Quarto binary (search PATH or common /opt/quarto/* location) TMP_SCRIPT="/tmp/quarto-chromium-install-${NB_USER}.sh" cat > "${TMP_SCRIPT}" <<'BASH' @@ -101,6 +133,59 @@ find_chromium_bin() { return 1 } +# Wrap the real Chromium binary IN PLACE with a CI-safe shim. +# +# Why: courseware render CI runs the container as root in a restricted +# docker context. Quarto's own browser spawn (criClient in quarto.js) +# passes --no-sandbox --disable-gpu but NOT --disable-dev-shm-usage, and +# the default 64 MB /dev/shm is too small for headless Chrome, so the +# Chromium child dies at startup and Quarto's error path reports a bare +# "ERROR: AssertionError:" instead of the real cause. The shim bakes in +# the CI-safe flags for every invocation. +# +# In-place wrapping keeps all of Quarto's discovery routes on the shim: +# the /usr/local/bin + ~/.local/bin symlinks created below, and Quarto's +# own install dir (~/.local/share/quarto/chromium/...), all resolve to +# this same file. +wrap_chromium_shim() { + local bin="$1" + local real bin_name resolved + resolved="$(readlink -f "${bin}" 2>/dev/null || true)" + if [ -n "${resolved}" ]; then + bin="${resolved}" + fi + real="${bin}.real" + bin_name="$(basename "${bin}")" + + # Idempotent: a shim is already in place. + if [ -e "${bin}" ] && head -n 5 "${bin}" 2>/dev/null | grep -q 'solen-chromium-shim'; then + echo "quarto-chromium: shim already in place at ${bin}" + return 0 + fi + + if [ -e "${bin}" ]; then + # Fresh binary at the public path (first run, or 'quarto install + # chromium' replaced the shim on a re-run): move it aside, overwriting + # any stale .real. + mv -f "${bin}" "${real}" + elif [ ! -e "${real}" ]; then + echo "quarto-chromium: ERROR: no Chromium binary at ${bin} to wrap" >&2 + return 1 + fi + # else: public path gone but .real remains — fall through and re-write + # the shim pointing at it. + + cat > "${bin}" <&2 for search_dir in \ From aaa4897db8db194d400ba490cb3208519f8a3248 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Mon, 7 Sep 2026 21:38:02 +0200 Subject: [PATCH 11/14] fix(quarto-cli): expose bundled typst on PATH --- features/quarto-cli/install.sh | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/features/quarto-cli/install.sh b/features/quarto-cli/install.sh index 1f9f1e1..d040d1e 100755 --- a/features/quarto-cli/install.sh +++ b/features/quarto-cli/install.sh @@ -149,11 +149,32 @@ fi echo "quarto-cli: found runtime at ${INSTDIR} -> ${QUARTO_EXE}" +# The tarball ships bundled tools (typst, pandoc, deno, ...) under +# /bin/tools// (arch = x86_64 | aarch64). Quarto +# itself locates them relative to its own binary (QUARTO_BIN_PATH), so +# renders work without PATH; expose the dir on PATH so that direct +# `typst` invocations (user notebook cells, scripts) resolve too. +QUARTO_TOOLS_DIR="" +for cand in "$(dirname "$QUARTO_EXE")"/tools/*/typst; do + if [ -x "$cand" ]; then + QUARTO_TOOLS_DIR="$(dirname "$cand")" + break + fi +done +if [ -n "${QUARTO_TOOLS_DIR}" ]; then + echo "quarto-cli: bundled tools dir at ${QUARTO_TOOLS_DIR}" +fi + +QUARTO_PATH_ADD="${CONDA_DIR}/bin:$(dirname "$QUARTO_EXE")" +if [ -n "${QUARTO_TOOLS_DIR}" ]; then + QUARTO_PATH_ADD="${QUARTO_PATH_ADD}:${QUARTO_TOOLS_DIR}" +fi + # Create wrapper in /usr/local/bin mkdir -p /usr/local/bin cat > /usr/local/bin/quarto < /etc/profile.d/quarto.sh < Date: Tue, 8 Sep 2026 00:17:36 +0200 Subject: [PATCH 12/14] fix(quarto-chromium): resolve shim target through symlinks The generated wrapper derived the .real target from dirname("$0"), which breaks when the shim is invoked via the /usr/local/bin or ~/.local/bin symlinks (the common PATH resolution route): $0 points at the symlink, so the wrapper looked for .real in the symlink directory and failed with exit 127. The wrapper now resolves its own canonical path via readlink -f first. Verified in-container with a fake binary: old logic failed via symlink (exec: /usr/local/bin/chrome.real: not found, rc=127); new logic passes via both symlinks and direct invocation, and re-wrap idempotency / original-restoration scenarios still pass. --- features/quarto-chromium/install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index e868be8..2269649 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -179,7 +179,11 @@ wrap_chromium_shim() { #!/bin/sh # solen-chromium-shim: CI-safe flags for headless Chromium in restricted # containers (baked by features/quarto-chromium; original: ${bin_name}.real) -exec "\$(dirname "\$0")/${bin_name}.real" --no-sandbox --disable-gpu --disable-dev-shm-usage "\$@" +# Resolve our canonical path first: this shim is commonly invoked through +# the /usr/local/bin + ~/.local/bin symlinks, where \$0 would point at the +# symlink and dirname(\$0) would miss ${bin_name}.real next to the original. +self="\$(readlink -f "\$0" 2>/dev/null || echo "\$0")" +exec "\$(dirname "\$self")/${bin_name}.real" --no-sandbox --disable-gpu --disable-dev-shm-usage "\$@" SHIM chmod 0755 "${bin}" chown "${NB_UID}":"${NB_GID}" "${bin}" "${real}" || true From 6d2e5ae6668b46af40c4d8c0cfd05b8761401e39 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Wed, 9 Sep 2026 14:08:03 +0200 Subject: [PATCH 13/14] chore(quarto): upgrade to 1.10.18 (fix mermaid svg-to-png internal render error) --- checksums.json | 6 +++++- features/quarto-cli/feature.json | 2 +- scripts/utils/update-checksums.sh | 2 +- versions.json | 2 +- versions/versions.yaml | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/checksums.json b/checksums.json index 9222a6f..df59297 100644 --- a/checksums.json +++ b/checksums.json @@ -121,7 +121,7 @@ } }, "quarto": { - "pinned_version": "1.8.24", + "pinned_version": "1.10.18", "archive_format": "tar.gz / deb / pkg / msi", "archive_url_template": "https://github.com/quarto-dev/quarto-cli/releases/download/v{version}/quarto-{version}-linux-{arch}.tar.gz", "checksum_asset": "quarto-{version}-checksums.txt", @@ -131,6 +131,10 @@ "1.8.24": { "amd64": "6b83c1c9b6f2ce6454798b42260bd2ee184551d74debe817b8aaf28b09ac22d0", "arm64": "89a97a65a242a5b9b010a9f9978928c1d8e4ac02a558c9cd91a110c3f2611fdd" + }, + "1.10.18": { + "amd64": "afad071b5bd22c02f2d300695743189d3650e0537a53073e654b630cff2b0c73", + "arm64": "f6a07df68e25330b5df34f65d3df66bca605acce3b830c593a58e91884d4cf6c" } } }, diff --git a/features/quarto-cli/feature.json b/features/quarto-cli/feature.json index ba6de83..17f7812 100644 --- a/features/quarto-cli/feature.json +++ b/features/quarto-cli/feature.json @@ -10,7 +10,7 @@ "options": { "QUARTO_VERSION": { "type": "string", - "default": "1.8.24", + "default": "1.10.18", "description": "Specific Quarto version to install" } }, diff --git a/scripts/utils/update-checksums.sh b/scripts/utils/update-checksums.sh index 26882b5..5f59cad 100755 --- a/scripts/utils/update-checksums.sh +++ b/scripts/utils/update-checksums.sh @@ -3,7 +3,7 @@ set -euo pipefail # Script to download binaries for pinned versions and compute SHA256 checksums # It updates `checksums.json` (repo root) with entries like: -# { "quarto": { "1.8.24": { "amd64": "" } } } +# { "quarto": { "1.10.18": { "amd64": "" } } } REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) # Single source of truth: repo-root checksums.json and versions.json diff --git a/versions.json b/versions.json index 8b7470a..48e5e31 100644 --- a/versions.json +++ b/versions.json @@ -6,7 +6,7 @@ "buildx": "v0.30.1", "compose": "v5.0.1", "docker": "27-cli", - "quarto": "v1.8.26", + "quarto": "v1.10.18", "node": "22.12.0", "docker-ce": "27.5.1", "docker-compose": "v5.0.1", diff --git a/versions/versions.yaml b/versions/versions.yaml index feb6cf4..e415350 100644 --- a/versions/versions.yaml +++ b/versions/versions.yaml @@ -21,7 +21,7 @@ tools: - type: github repo: "docker/cli" quarto: - version: "v1.8.26" + version: "v1.10.18" sources: - type: github repo: "quarto-dev/quarto-cli" From a712c14dbda7464d736537966a6efe42019d73a7 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Thu, 10 Sep 2026 15:45:03 +0200 Subject: [PATCH 14/14] fix(quarto-chromium): support Quarto 1.10 chrome-headless-shell --- features/quarto-chromium/feature.json | 2 +- features/quarto-chromium/install.sh | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/features/quarto-chromium/feature.json b/features/quarto-chromium/feature.json index b1ea2a8..cf85f69 100644 --- a/features/quarto-chromium/feature.json +++ b/features/quarto-chromium/feature.json @@ -19,7 +19,7 @@ "email": "emmanuel.bruno@univ-tln.fr" }, "postInstallCheck": { - "command": "chromium_bin=\"$(command -v chromium || command -v chromium-browser || true)\"\nif [ -z \"${chromium_bin}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - no chromium/chromium-browser on PATH\" >&2\n exit 1\nfi\nerr_file=\"$(mktemp)\"\nout=\"$(timeout 60 \"${chromium_bin}\" --headless --disable-gpu --dump-dom about:blank 2>\"${err_file}\")\" && rc=0 || rc=$?\nif [ \"${rc}\" -ne 0 ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium exited ${rc}\" >&2\n cat \"${err_file}\" >&2\n rm -f \"${err_file}\"\n exit 1\nfi\nrm -f \"${err_file}\"\nif [ -z \"${out}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium produced no output\" >&2\n exit 1\nfi\necho \"quarto-chromium: check OK (headless chromium rendered via ${chromium_bin})\"", + "command": "chromium_bin=\"$(command -v chromium || command -v chromium-browser || command -v chrome-headless-shell || command -v headless_shell || true)\"\nif [ -z \"${chromium_bin}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - no chromium/chromium-browser/chrome-headless-shell on PATH\" >&2\n exit 1\nfi\nerr_file=\"$(mktemp)\"\nbin_base=\"$(basename \"${chromium_bin}\")\"\ncase \"${bin_base}\" in\n chrome-headless-shell|headless_shell)\n out=\"$(timeout 60 \"${chromium_bin}\" --disable-gpu --dump-dom about:blank 2>\"${err_file}\")\" && rc=0 || rc=$?\n ;;\n *)\n out=\"$(timeout 60 \"${chromium_bin}\" --headless --disable-gpu --dump-dom about:blank 2>\"${err_file}\")\" && rc=0 || rc=$?\n ;;\nesac\nif [ \"${rc}\" -ne 0 ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium exited ${rc}\" >&2\n cat \"${err_file}\" >&2\n rm -f \"${err_file}\"\n exit 1\nfi\nrm -f \"${err_file}\"\nif [ -z \"${out}\" ]; then\n echo \"quarto-chromium: CHECK FAIL - headless chromium produced no output\" >&2\n exit 1\nfi\necho \"quarto-chromium: check OK (headless chromium rendered via ${chromium_bin})\"", "description": "Functionally verify headless Chromium starts and renders (CI-safe shim + shared libraries), not just that the binary exists" }, "documentationURL": "./features/quarto-chromium/README.md" diff --git a/features/quarto-chromium/install.sh b/features/quarto-chromium/install.sh index 2269649..4a6876a 100755 --- a/features/quarto-chromium/install.sh +++ b/features/quarto-chromium/install.sh @@ -65,7 +65,9 @@ if [ -z "${QUARTO_BIN}" ]; then done fi if [ -n "${QUARTO_BIN}" ] && [ -x "${QUARTO_BIN}" ]; then - CI=true "${QUARTO_BIN}" install --no-prompt --log "${HOME}/.quarto-chromium-install.log" --log-level debug chromium \ + CI=true "${QUARTO_BIN}" install --no-prompt --log "${HOME}/.quarto-chromium-install.log" --log-level debug chrome-headless-shell \ + || CI=true "${QUARTO_BIN}" install chrome-headless-shell --no-prompt \ + || CI=true "${QUARTO_BIN}" install --no-prompt --log "${HOME}/.quarto-chromium-install.log" --log-level debug chromium \ || CI=true "${QUARTO_BIN}" install chromium --no-prompt \ || true else @@ -83,7 +85,10 @@ find_chromium_bin() { "${HOME_DIR}/.quarto/bin/chromium" \ "${HOME_DIR}/.quarto/bin/chromium-browser" \ "${HOME_DIR}/.quarto/bin/chrome" \ + "${HOME_DIR}/.quarto/bin/chrome-headless-shell" \ + "${HOME_DIR}/.quarto/bin/headless_shell" \ "${HOME_DIR}/.local/share/quarto/chromium/linux-"*/chrome-linux/chrome \ + "${HOME_DIR}/.local/share/quarto/chrome-headless-shell/linux-"*/chrome-headless-shell \ "${HOME_DIR}/.local/share/quarto-chromium/chromium" \ "${HOME_DIR}/.local/share/quarto-chromium/chromium-browser" \ "${HOME_DIR}/.local/share/quarto-chromium/chrome" \ @@ -116,7 +121,7 @@ find_chromium_bin() { do [ -d "${search_dir}" ] || continue found=$( - find "${search_dir}" \( -type f -o -type l \) \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | + find "${search_dir}" \( -type f -o -type l \) \( -name chrome -o -name chrome-headless-shell -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | while IFS= read -r candidate; do if [ -x "${candidate}" ]; then printf '%s\n' "${candidate}" @@ -235,14 +240,18 @@ if [ -n "${CHROMIUM_BIN}" ]; then mkdir -p "${HOME_DIR}/.local/bin" || true ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium || true ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chromium-browser || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/chrome-headless-shell || true + ln -sf "${CHROMIUM_BIN}" /usr/local/bin/headless_shell || true ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chromium" || true ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chromium-browser" || true - chown "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/bin/chromium" "${HOME_DIR}/.local/bin/chromium-browser" || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/chrome-headless-shell" || true + ln -sf "${CHROMIUM_BIN}" "${HOME_DIR}/.local/bin/headless_shell" || true + chown "${NB_UID}":"${NB_GID}" "${HOME_DIR}/.local/bin/chromium" "${HOME_DIR}/.local/bin/chromium-browser" "${HOME_DIR}/.local/bin/chrome-headless-shell" "${HOME_DIR}/.local/bin/headless_shell" || true # Bake the CI-safe flags into the binary itself (idempotent). wrap_chromium_shim "${CHROMIUM_BIN}" else - echo "quarto-chromium: WARNING: Chromium binary not found after installation" >&2 + echo "quarto-chromium: ERROR: Chromium/Chrome Headless Shell binary not found after installation" >&2 for search_dir in \ "${HOME_DIR}/.local/share/quarto/chromium" \ "${HOME_DIR}/.local/share/quarto" \ @@ -254,12 +263,13 @@ else /var/cache do [ -d "${search_dir}" ] || continue - find "${search_dir}" -maxdepth 6 \( -type f -o -type l \) \( -name chrome -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | head -n 20 || true + find "${search_dir}" -maxdepth 6 \( -type f -o -type l \) \( -name chrome -o -name chrome-headless-shell -o -name chromium -o -name chromium-browser -o -name headless_shell \) 2>/dev/null | head -n 20 || true done if [ -f "${HOME_DIR}/.quarto-chromium-install.log" ]; then echo "quarto-chromium: Quarto install log tail:" >&2 tail -n 40 "${HOME_DIR}/.quarto-chromium-install.log" >&2 || true fi + exit 1 fi echo "quarto-chromium: installation complete"