From 3af329e71fadc65bce81e5978a1ef5237c530b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:44:21 +0200 Subject: [PATCH 1/7] fix: use portable sed and strict mode in init-env.sh, wire decktape version option The BSD-only 'sed -i ""' form fails on Linux where the script runs. The decktape feature option was named 'node' while its install script reads VERSION, so the option value never reached the script. --- .../decktape/devcontainer-feature.json | 2 +- .github/.devcontainer/devcontainer.json | 2 +- init-env.sh | 29 +++++++------------ 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/.devcontainer/decktape/devcontainer-feature.json b/.github/.devcontainer/decktape/devcontainer-feature.json index e123b93..3938d5f 100644 --- a/.github/.devcontainer/decktape/devcontainer-feature.json +++ b/.github/.devcontainer/decktape/devcontainer-feature.json @@ -4,7 +4,7 @@ "name": "decktape", "description": "Install decktape, a tool for generating PDFs from web pages.", "options": { - "node": { + "version": { "default": "22", "description": "Select the Node version to install.", "proposals": ["22"], diff --git a/.github/.devcontainer/devcontainer.json b/.github/.devcontainer/devcontainer.json index aba362b..9c72d73 100644 --- a/.github/.devcontainer/devcontainer.json +++ b/.github/.devcontainer/devcontainer.json @@ -61,7 +61,7 @@ }, "./chrome": {}, "./decktape": { - "node": "22" + "version": "22" }, "./tinytex": {}, "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": { diff --git a/init-env.sh b/init-env.sh index ba8468d..c033486 100755 --- a/init-env.sh +++ b/init-env.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +set -euo pipefail + show_help() { echo "Usage: $0 [--what/-w all|r|python|julia] [--force/-f] [--help/-h]" echo " --what/-w: Specify what to initialise (default: all)." @@ -13,10 +15,10 @@ show_help() { initialise_r() { local deps=$1 - deps=$(echo "${deps}" | sed 's/,/","/g') + deps=${deps//,/\",\"} if [ "${FORCE}" = true ] || [ ! -f "renv.lock" ]; then if [ -f ".Rprofile" ] && grep -q 'source("renv/activate.R")' .Rprofile; then - sed -i '' '/source("renv\/activate.R")/d' .Rprofile + sed -i.bak '/source("renv\/activate.R")/d' .Rprofile && rm -f .Rprofile.bak fi Rscript -e 'renv::init(bare = FALSE)' Rscript -e "renv::install(c('${deps}'))" @@ -24,24 +26,15 @@ initialise_r() { fi } -initialise_python() { - local deps=$1 - deps=$(echo "${deps}" | sed 's/,/ /g') - if [ "${FORCE}" = true ] || [ ! -f "requirements.txt" ]; then - python3 -m venv .venv - source .venv/bin/activate - python3 -m pip install ${deps} - python3 -m pip freeze > requirements.txt - fi -} - initialise_uv() { local deps=$1 - deps=$(echo "${deps}" | sed 's/,/ /g') + deps=${deps//,/ } if [ "${FORCE}" = true ] || [ ! -f "uv.lock" ]; then uv init --no-package --vcs none --bare --no-readme --author-from none uv venv + # shellcheck disable=SC1091 source .venv/bin/activate + # shellcheck disable=SC2086 uv add ${deps} uv sync fi @@ -49,7 +42,7 @@ initialise_uv() { initialise_julia() { local deps=$1 - deps=$(echo "${deps}" | sed 's/,/","/g') + deps=${deps//,/\",\"} if [ "${FORCE}" = true ] || [ ! -f "Project.toml" ]; then julia -e 'using Pkg; Pkg.activate("."); Pkg.instantiate()' julia --project=. -e "using Pkg; Pkg.add([\"${deps}\"])" @@ -61,14 +54,14 @@ FORCE=false while [[ "$#" -gt 0 ]]; do case $1 in - --what|-w) + --what | -w) WHAT="$2" shift ;; - --force|-f) + --force | -f) FORCE=true ;; - --help|-h) + --help | -h) show_help exit 0 ;; From 28d00fb8e5f5159974084a75f113396fbaea4617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:44:27 +0200 Subject: [PATCH 2/7] fix: support pinned uv versions and harden installer downloads GitHub release URLs differ between latest and pinned tags; the single URL pattern only worked for latest. Remote installer downloads now enforce HTTPS with TLS 1.2 and fail on HTTP errors so error pages are never piped to sh. Google Chrome apt repository switched to HTTPS. Unused USERNAME resolution blocks and leftover VERSION variable removed from feature scripts that never used them. --- .devcontainer/universal/uv/install.sh | 26 ++++++----------------- .github/.devcontainer/chrome/install.sh | 25 ++-------------------- .github/.devcontainer/decktape/install.sh | 19 ----------------- .github/.devcontainer/tinytex/install.sh | 2 +- .github/.devcontainer/uv/install.sh | 26 ++++++----------------- 5 files changed, 15 insertions(+), 83 deletions(-) diff --git a/.devcontainer/universal/uv/install.sh b/.devcontainer/universal/uv/install.sh index 51d0f95..f07bd28 100644 --- a/.devcontainer/universal/uv/install.sh +++ b/.devcontainer/universal/uv/install.sh @@ -4,8 +4,6 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive -USERNAME=${USERNAME:-${_REMOTE_USER:-"automatic"}} - VERSION=${VERSION:-"latest"} if [ "$(id -u)" -ne 0 ]; then @@ -13,23 +11,6 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u "${CURRENT_USER}" >/dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root - fi -elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" >/dev/null 2>&1; then - USERNAME=root -fi - apt_get_update() { if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then echo "Running apt-get update..." @@ -47,7 +28,12 @@ check_packages() { install_uv() { local version=$1 - local url="https://github.com/astral-sh/uv/releases/${version}/download/uv-installer.sh" + local url + if [ "${version}" = "latest" ]; then + url="https://github.com/astral-sh/uv/releases/latest/download/uv-installer.sh" + else + url="https://github.com/astral-sh/uv/releases/download/${version}/uv-installer.sh" + fi check_packages curl ca-certificates curl --proto '=https' --tlsv1.2 -LsSf "${url}" | env UV_INSTALL_DIR="/usr/local/bin" sh } diff --git a/.github/.devcontainer/chrome/install.sh b/.github/.devcontainer/chrome/install.sh index ce693b5..20f5c47 100755 --- a/.github/.devcontainer/chrome/install.sh +++ b/.github/.devcontainer/chrome/install.sh @@ -4,10 +4,6 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive -USERNAME=${USERNAME:-${_REMOTE_USER:-"automatic"}} - -VERSION=${VERSION:-"22"} - if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -19,23 +15,6 @@ if [ "${arch}" != "amd64" ] && [ "${arch}" != "arm64" ]; then exit 2 fi -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u "${CURRENT_USER}" >/dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root - fi -elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" >/dev/null 2>&1; then - USERNAME=root -fi - apt_get_update() { if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then echo "Running apt-get update..." @@ -54,8 +33,8 @@ check_packages() { install_chrome() { local arch="$1" check_packages curl gnupg - curl -fSsL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor | tee /usr/share/keyrings/google-chrome.gpg >>/dev/null - echo "deb [arch=${arch} signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list + curl --proto '=https' --tlsv1.2 -fSsL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor | tee /usr/share/keyrings/google-chrome.gpg >>/dev/null + echo "deb [arch=${arch} signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list apt_get_update check_packages google-chrome-stable } diff --git a/.github/.devcontainer/decktape/install.sh b/.github/.devcontainer/decktape/install.sh index c324f26..1ff6a1c 100755 --- a/.github/.devcontainer/decktape/install.sh +++ b/.github/.devcontainer/decktape/install.sh @@ -4,8 +4,6 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive -USERNAME=${USERNAME:-${_REMOTE_USER:-"automatic"}} - VERSION=${VERSION:-"22"} if [ "$(id -u)" -ne 0 ]; then @@ -13,23 +11,6 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u "${CURRENT_USER}" >/dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root - fi -elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" >/dev/null 2>&1; then - USERNAME=root -fi - apt_get_update() { if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then echo "Running apt-get update..." diff --git a/.github/.devcontainer/tinytex/install.sh b/.github/.devcontainer/tinytex/install.sh index d4ce06d..4216f00 100644 --- a/.github/.devcontainer/tinytex/install.sh +++ b/.github/.devcontainer/tinytex/install.sh @@ -54,7 +54,7 @@ install_tinytex() { check_packages libfontconfig # su "${USERNAME}" -c 'quarto install tinytex --quiet' check_packages curl ca-certificates - curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh + curl --proto '=https' --tlsv1.2 -fsSL "https://yihui.org/tinytex/install-bin-unix.sh" | sh TINYTEX_OPT="/opt/tinytex" mv /root/.TinyTeX "${TINYTEX_OPT}" TINYTEX_INSTALL_DIR="${TINYTEX_OPT}/bin/$(uname -m)-linux" diff --git a/.github/.devcontainer/uv/install.sh b/.github/.devcontainer/uv/install.sh index 0b05b7c..5d7c9f9 100644 --- a/.github/.devcontainer/uv/install.sh +++ b/.github/.devcontainer/uv/install.sh @@ -4,8 +4,6 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive -USERNAME=${USERNAME:-${_REMOTE_USER:-"automatic"}} - VERSION=${VERSION:-"latest"} if [ "$(id -u)" -ne 0 ]; then @@ -19,23 +17,6 @@ if [ "${architecture}" != "amd64" ] && [ "${architecture}" != "arm64" ]; then exit 2 fi -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u "${CURRENT_USER}" >/dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root - fi -elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" >/dev/null 2>&1; then - USERNAME=root -fi - apt_get_update() { if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then echo "Running apt-get update..." @@ -53,7 +34,12 @@ check_packages() { install_uv() { local version=$1 - local url="https://github.com/astral-sh/uv/releases/${version}/download/uv-installer.sh" + local url + if [ "${version}" = "latest" ]; then + url="https://github.com/astral-sh/uv/releases/latest/download/uv-installer.sh" + else + url="https://github.com/astral-sh/uv/releases/download/${version}/uv-installer.sh" + fi check_packages curl ca-certificates curl --proto '=https' --tlsv1.2 -LsSf "${url}" | env UV_INSTALL_DIR="/usr/local/bin" sh } From ee0997d9f4c50c3986fbe2684786a994fceedf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:50:58 +0200 Subject: [PATCH 3/7] chore: sync duplicated dev container features and enforce via CI The uv and quarto-computing-dependencies features exist in both .github/.devcontainer (canonical) and .devcontainer/universal, and the copies had drifted (python version pin, platform gate, arch guard). Symlinking was ruled out: the devcontainer CLI fails to fetch local features through symlinked directories. Copies are now identical, the universal config keeps its amd64-only behaviour via the installOnPlatforms option, and a check-feature-sync job fails the build when the copies diverge. --- .devcontainer/universal/devcontainer.json | 1 + .../devcontainer-feature.json | 9 +++++++- .../quarto-computing-dependencies/install.sh | 22 +++++++++++++++---- .../universal/uv/devcontainer-feature.json | 5 ++++- .devcontainer/universal/uv/install.sh | 9 ++++++++ .github/workflows/devcontainer.yml | 14 ++++++++++++ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.devcontainer/universal/devcontainer.json b/.devcontainer/universal/devcontainer.json index 6d4fd81..5955713 100644 --- a/.devcontainer/universal/devcontainer.json +++ b/.devcontainer/universal/devcontainer.json @@ -26,6 +26,7 @@ "remoteUser": "codespace", "features": { "./quarto-computing-dependencies": { + "installOnPlatforms": "amd64", "rDeps": "rmarkdown,languageserver,prompt,lintr", "pythonDeps": "jupyter,papermill", "juliaDeps": "IJulia" diff --git a/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json b/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json index 595fc69..9338051 100644 --- a/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json +++ b/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json @@ -4,6 +4,11 @@ "name": "Install Computing Dependencies for Quarto", "description": "Install R, Python, and Julia dependencies for Quarto.", "options": { + "installOnPlatforms": { + "type": "string", + "default": "amd64,arm64", + "description": "Comma-separated list of platforms on which to install dependencies." + }, "rDeps": { "type": "string", "default": "rmarkdown", @@ -29,7 +34,7 @@ "installRMarkdown": "false" }, "ghcr.io/devcontainers/features/python:1": { - "version": "latest", + "version": "3.13.9", "enableShared": "true" }, "ghcr.io/julialang/devcontainer-features/julia:1": { @@ -37,6 +42,8 @@ } }, "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/git", "ghcr.io/rocker-org/devcontainer-features/r-rig", "ghcr.io/devcontainers/features/python", "ghcr.io/julialang/devcontainer-features/julia" diff --git a/.devcontainer/universal/quarto-computing-dependencies/install.sh b/.devcontainer/universal/quarto-computing-dependencies/install.sh index c3ad9c0..e9a7f40 100755 --- a/.devcontainer/universal/quarto-computing-dependencies/install.sh +++ b/.devcontainer/universal/quarto-computing-dependencies/install.sh @@ -6,6 +6,8 @@ export DEBIAN_FRONTEND=noninteractive USERNAME=${USERNAME:-${_REMOTE_USER:-"automatic"}} +PLATFORMS=${INSTALL_ON_PLATFORMS:-"amd64,arm64"} + R_DEPS=${RDEPS:-"rmarkdown"} PYTHON_DEPS=${PYTHONDEPS:-"jupyter,papermill"} JULIA_DEPS=${JULIADEPS:-"IJulia"} @@ -15,6 +17,12 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +architecture="$(dpkg --print-architecture)" +if [ "${architecture}" != "amd64" ] && [ "${architecture}" != "arm64" ]; then + echo "(!) Architecture ${architecture} unsupported" + exit 2 +fi + # Determine the appropriate non-root user if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then USERNAME="" @@ -52,9 +60,15 @@ quarto_julia_deps() { su "${USERNAME}" -c "~/.juliaup/bin/julia -e 'using Pkg; Pkg.add.([\"${deps}\"])'" } -apt-get update -y && apt-get install -y --no-install-recommends libuv1-dev && rm -rf /var/lib/apt/lists/* -quarto_r_deps "${R_DEPS}" -quarto_python_deps "${PYTHON_DEPS}" -quarto_julia_deps "${JULIA_DEPS}" +if [[ ",${PLATFORMS}," == *",${architecture},"* ]]; then + apt-get update -y && apt-get install -y --no-install-recommends libuv1-dev && rm -rf /var/lib/apt/lists/* + quarto_r_deps "${R_DEPS}" + quarto_python_deps "${PYTHON_DEPS}" + quarto_julia_deps "${JULIA_DEPS}" +else + echo "(!) Skipping R, Python, and Julia dependencies for ${architecture} architecture" +fi apt-get clean && rm -rf /var/lib/apt/lists/* + +echo "Done!" diff --git a/.devcontainer/universal/uv/devcontainer-feature.json b/.devcontainer/universal/uv/devcontainer-feature.json index fd8a204..a252922 100644 --- a/.devcontainer/universal/uv/devcontainer-feature.json +++ b/.devcontainer/universal/uv/devcontainer-feature.json @@ -11,5 +11,8 @@ "type": "string" } }, - "installsAfter": ["./quarto-computing-dependencies"] + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils", + "./quarto-computing-dependencies" + ] } diff --git a/.devcontainer/universal/uv/install.sh b/.devcontainer/universal/uv/install.sh index f07bd28..5d7c9f9 100644 --- a/.devcontainer/universal/uv/install.sh +++ b/.devcontainer/universal/uv/install.sh @@ -11,6 +11,12 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +architecture="$(dpkg --print-architecture)" +if [ "${architecture}" != "amd64" ] && [ "${architecture}" != "arm64" ]; then + echo "(!) Architecture ${architecture} unsupported" + exit 2 +fi + apt_get_update() { if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then echo "Running apt-get update..." @@ -45,4 +51,7 @@ enable_autocompletion() { install_uv "${VERSION}" enable_autocompletion + apt-get clean && rm -rf /var/lib/apt/lists/* + +echo "Done!" diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index debf5cf..727f550 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -17,9 +17,23 @@ permissions: packages: write jobs: + check-feature-sync: + runs-on: ubuntu-latest + name: Check - Duplicated features in sync + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Verify duplicated dev container features are identical + run: | + diff -r .github/.devcontainer/uv .devcontainer/universal/uv + diff -r .github/.devcontainer/quarto-computing-dependencies .devcontainer/universal/quarto-computing-dependencies + build: runs-on: ubuntu-latest name: Build - Quarto ${{ matrix.QUARTO_VERSION }} + needs: + - "check-feature-sync" if: >- ! endsWith(github.event.pull_request.user.login, '[bot]') && ( From d8316fdb02ed3534e53210ad319abb478c35b217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:51:13 +0200 Subject: [PATCH 4/7] fix: unpin python feature version now upstream issue is resolved The 3.13.9 pin worked around devcontainers/features#1501, which broke installing the python feature at latest. Upstream fix is merged and released, so both feature copies return to latest. Closes #54 --- .../quarto-computing-dependencies/devcontainer-feature.json | 2 +- .../quarto-computing-dependencies/devcontainer-feature.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json b/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json index 9338051..b0f138c 100644 --- a/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json +++ b/.devcontainer/universal/quarto-computing-dependencies/devcontainer-feature.json @@ -34,7 +34,7 @@ "installRMarkdown": "false" }, "ghcr.io/devcontainers/features/python:1": { - "version": "3.13.9", + "version": "latest", "enableShared": "true" }, "ghcr.io/julialang/devcontainer-features/julia:1": { diff --git a/.github/.devcontainer/quarto-computing-dependencies/devcontainer-feature.json b/.github/.devcontainer/quarto-computing-dependencies/devcontainer-feature.json index 9338051..b0f138c 100644 --- a/.github/.devcontainer/quarto-computing-dependencies/devcontainer-feature.json +++ b/.github/.devcontainer/quarto-computing-dependencies/devcontainer-feature.json @@ -34,7 +34,7 @@ "installRMarkdown": "false" }, "ghcr.io/devcontainers/features/python:1": { - "version": "3.13.9", + "version": "latest", "enableShared": "true" }, "ghcr.io/julialang/devcontainer-features/julia:1": { From 796d45fe288e06a69d2625f77babffa24a03d6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:59:34 +0200 Subject: [PATCH 5/7] feat: build multi-architecture images on native arm64 runners Images were built for linux/amd64 only, and the arm64 path was blocked by Google Chrome, which has no arm64 Debian package. The chrome feature now installs Chromium from Playwright's arm64 builds and exposes the browser at /usr/local/bin/chromium on both architectures, wired to Quarto and puppeteer through QUARTO_CHROMIUM and PUPPETEER_EXECUTABLE_PATH. Decktape no longer downloads its own amd64-only Chromium and installs before the chrome feature so its Node.js is the one used to fetch Chromium. Release and prerelease images build natively and in parallel on ubuntu-latest and ubuntu-24.04-arm, publish per-architecture tags, and a merge job combines them into the published multi-architecture tags. QEMU emulation is gone, which was the source of the very long arm64 build times. Versioned images stay amd64-only because Quarto releases before 1.4 have no arm64 packages. Closes #37 --- .../chrome/devcontainer-feature.json | 11 +- .github/.devcontainer/chrome/install.sh | 33 ++++- .../decktape/devcontainer-feature.json | 3 +- .github/.devcontainer/decktape/install.sh | 4 +- .github/.devcontainer/devcontainer.json | 1 - .github/workflows/devcontainer.yml | 135 ++++++++++++------ 6 files changed, 129 insertions(+), 58 deletions(-) diff --git a/.github/.devcontainer/chrome/devcontainer-feature.json b/.github/.devcontainer/chrome/devcontainer-feature.json index e78f5d3..8f4a378 100644 --- a/.github/.devcontainer/chrome/devcontainer-feature.json +++ b/.github/.devcontainer/chrome/devcontainer-feature.json @@ -1,10 +1,15 @@ { "id": "chrome", - "version": "1.0.0", + "version": "1.1.0", "name": "Chrome", - "description": "Install Google Chrome web browser.", + "description": "Install Google Chrome (amd64) or Chromium (arm64) web browser.", + "containerEnv": { + "PUPPETEER_EXECUTABLE_PATH": "/usr/local/bin/chromium", + "QUARTO_CHROMIUM": "/usr/local/bin/chromium" + }, "installsAfter": [ "ghcr.io/devcontainers/features/common-utils", - "ghcr.io/devcontainers/features/git" + "ghcr.io/devcontainers/features/git", + "./decktape" ] } diff --git a/.github/.devcontainer/chrome/install.sh b/.github/.devcontainer/chrome/install.sh index 20f5c47..02350fd 100755 --- a/.github/.devcontainer/chrome/install.sh +++ b/.github/.devcontainer/chrome/install.sh @@ -30,16 +30,41 @@ check_packages() { fi } -install_chrome() { - local arch="$1" +# Google only ships amd64 debs; arm64 gets Chromium via Playwright's builds. +# Both arches expose the browser as /usr/local/bin/chromium (see containerEnv). +install_chrome_amd64() { check_packages curl gnupg curl --proto '=https' --tlsv1.2 -fSsL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor | tee /usr/share/keyrings/google-chrome.gpg >>/dev/null - echo "deb [arch=${arch} signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list apt_get_update check_packages google-chrome-stable + ln -sf /usr/bin/google-chrome-stable /usr/local/bin/chromium } -install_chrome "${arch}" +install_chromium_arm64() { + check_packages curl ca-certificates + # The decktape feature installs Node.js first; fall back to the distribution + # packages so this feature also works on its own. + if ! command -v npx >/dev/null 2>&1; then + check_packages nodejs npm + fi + export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright + npx --yes playwright install --with-deps chromium + local chrome_bin + chrome_bin=$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -path '*chrome-linux*' | head -n 1) + if [ -z "${chrome_bin}" ]; then + echo "(!) Chromium binary not found under ${PLAYWRIGHT_BROWSERS_PATH}" + exit 3 + fi + chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}" + ln -sf "${chrome_bin}" /usr/local/bin/chromium +} + +if [ "${arch}" = "amd64" ]; then + install_chrome_amd64 +else + install_chromium_arm64 +fi apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/.github/.devcontainer/decktape/devcontainer-feature.json b/.github/.devcontainer/decktape/devcontainer-feature.json index 3938d5f..7317db7 100644 --- a/.github/.devcontainer/decktape/devcontainer-feature.json +++ b/.github/.devcontainer/decktape/devcontainer-feature.json @@ -13,7 +13,6 @@ }, "installsAfter": [ "ghcr.io/devcontainers/features/common-utils", - "ghcr.io/devcontainers/features/git", - "./chrome" + "ghcr.io/devcontainers/features/git" ] } diff --git a/.github/.devcontainer/decktape/install.sh b/.github/.devcontainer/decktape/install.sh index 1ff6a1c..e15a790 100755 --- a/.github/.devcontainer/decktape/install.sh +++ b/.github/.devcontainer/decktape/install.sh @@ -32,7 +32,9 @@ install_decktape() { check_packages curl ca-certificates curl --proto '=https' --tlsv1.2 -LsSf "${url}" | bash - check_packages nodejs - npm install -g decktape + # The browser comes from the chrome feature (PUPPETEER_EXECUTABLE_PATH); + # puppeteer's bundled download is amd64-only and dead weight. + PUPPETEER_SKIP_DOWNLOAD=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm install -g decktape } install_decktape "${VERSION}" diff --git a/.github/.devcontainer/devcontainer.json b/.github/.devcontainer/devcontainer.json index 9c72d73..6bbfd85 100644 --- a/.github/.devcontainer/devcontainer.json +++ b/.github/.devcontainer/devcontainer.json @@ -51,7 +51,6 @@ "ppa": "false" }, "./quarto-computing-dependencies": { - "installOnPlatforms": "amd64", "rDeps": "rmarkdown,languageserver", "pythonDeps": "jupyter,papermill", "juliaDeps": "IJulia" diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index 727f550..e8ebe36 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -30,8 +30,8 @@ jobs: diff -r .github/.devcontainer/quarto-computing-dependencies .devcontainer/universal/quarto-computing-dependencies build: - runs-on: ubuntu-latest - name: Build - Quarto ${{ matrix.QUARTO_VERSION }} + runs-on: ${{ matrix.RUNNER }} + name: Build - Quarto ${{ matrix.QUARTO_VERSION }} (${{ matrix.ARCH }}) needs: - "check-feature-sync" if: >- @@ -43,20 +43,26 @@ jobs: github.event_name == 'pull_request' ) strategy: + fail-fast: false matrix: include: - QUARTO_VERSION: release - IMAGE_TAG_BASE: release,release-noble,latest + ARCH: amd64 + RUNNER: ubuntu-latest + - QUARTO_VERSION: release + ARCH: arm64 + RUNNER: ubuntu-24.04-arm - QUARTO_VERSION: prerelease - IMAGE_TAG_BASE: prerelease,prerelease-noble + ARCH: amd64 + RUNNER: ubuntu-latest + - QUARTO_VERSION: prerelease + ARCH: arm64 + RUNNER: ubuntu-24.04-arm steps: - name: Checkout code uses: actions/checkout@v7 - - name: Set up QEMU for multi-architecture builds - uses: docker/setup-qemu-action@v4 - - - name: Setup Docker buildx for multi-architecture builds + - name: Setup Docker buildx uses: docker/setup-buildx-action@v4 with: use: true @@ -77,37 +83,11 @@ jobs: echo "VERSION=$(git rev-parse --short HEAD)" >> ${GITHUB_OUTPUT} fi - - name: Define Image Tags with Build Metadata - id: image_tags - env: - BASE_TAGS: ${{ matrix.IMAGE_TAG_BASE }} - QUARTO_VERSION: ${{ matrix.QUARTO_VERSION }} - run: | - BUILD_ID=$(date -u +%Y%m%d%H%M) - echo "::group::Build Metadata Tags for ${QUARTO_VERSION}" - echo "Build ID: ${BUILD_ID}" - echo "Base tags: ${BASE_TAGS}" - - # Split base tags and add build metadata versions (exclude 'latest') - IFS=',' read -ra TAG_ARRAY <<< "${BASE_TAGS}" - TAGS_WITH_BUILD="" - for tag in "${TAG_ARRAY[@]}"; do - if [[ "${tag}" != "latest" ]]; then - TAGS_WITH_BUILD="${TAGS_WITH_BUILD},${tag}-${BUILD_ID}" - fi - done - - # Combine original tags with build metadata tags - ALL_TAGS="${BASE_TAGS}${TAGS_WITH_BUILD}" - echo "All tags: ${ALL_TAGS}" - echo "::endgroup::" - echo "IMAGE_TAG=${ALL_TAGS}" >> ${GITHUB_OUTPUT} - - name: Build and run Dev Container on release id: devcontainer-ci uses: devcontainers/ci@v0.3 with: - imageTag: ${{ steps.image_tags.outputs.IMAGE_TAG }} + imageTag: ${{ matrix.QUARTO_VERSION }}-${{ matrix.ARCH }} subFolder: .github imageName: ghcr.io/${{ github.repository }} noCache: true @@ -115,7 +95,7 @@ jobs: release workflow_dispatch schedule - platform: linux/amd64 + platform: linux/${{ matrix.ARCH }} runCmd: echo "$(CI=true quarto check 2>&1)" | sed '/^(/d; s/\x1b\[[0-9;]*[mGK]//g' env: @@ -133,6 +113,7 @@ jobs: - name: Test Dev Container env: QUARTO_VERSION: ${{ matrix.QUARTO_VERSION }} + ARCH: ${{ matrix.ARCH }} DEVCONTAINER_RUN_CMD: ${{ steps.devcontainer-ci.outputs.runCmdOutput }} run: | echo "::group::Quarto Check" @@ -140,7 +121,7 @@ jobs: echo "::endgroup::" ( - echo "## Quarto Check Results: ${QUARTO_VERSION}" + echo "## Quarto Check Results: ${QUARTO_VERSION} (${ARCH})" echo "
Quarto Check Results" echo "" echo '```txt' @@ -161,10 +142,73 @@ jobs: - name: Upload quarto check output uses: actions/upload-artifact@v7 with: - name: quarto-check-${{ matrix.QUARTO_VERSION }} + name: quarto-check-${{ matrix.QUARTO_VERSION }}-${{ matrix.ARCH }} path: /tmp/quarto-check-outputs/${{ matrix.QUARTO_VERSION }}.txt retention-days: 1 + merge: + runs-on: ubuntu-latest + name: Merge - Quarto ${{ matrix.QUARTO_VERSION }} manifests + + needs: + - "build" + + if: >- + github.event_name == 'release' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' + + strategy: + matrix: + include: + - QUARTO_VERSION: release + IMAGE_TAG_BASE: release,release-noble,latest + - QUARTO_VERSION: prerelease + IMAGE_TAG_BASE: prerelease,prerelease-noble + + steps: + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v4 + with: + use: true + + - name: Login to Docker registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Combine per-architecture images into multi-architecture tags + env: + BASE_TAGS: ${{ matrix.IMAGE_TAG_BASE }} + QUARTO_VERSION: ${{ matrix.QUARTO_VERSION }} + run: | + BUILD_ID=$(date -u +%Y%m%d%H%M) + IMAGE="ghcr.io/${GITHUB_REPOSITORY}" + + echo "::group::Manifest tags for ${QUARTO_VERSION}" + echo "Build ID: ${BUILD_ID}" + echo "Base tags: ${BASE_TAGS}" + + # Every base tag also gets a build metadata variant, except 'latest' + IFS=',' read -ra TAG_ARRAY <<< "${BASE_TAGS}" + TAG_ARGS=() + for tag in "${TAG_ARRAY[@]}"; do + TAG_ARGS+=(--tag "${IMAGE}:${tag}") + if [[ "${tag}" != "latest" ]]; then + TAG_ARGS+=(--tag "${IMAGE}:${tag}-${BUILD_ID}") + fi + done + echo "All tags: ${TAG_ARGS[*]}" + echo "::endgroup::" + + docker buildx imagetools create "${TAG_ARGS[@]}" \ + "${IMAGE}:${QUARTO_VERSION}-amd64" \ + "${IMAGE}:${QUARTO_VERSION}-arm64" + + docker buildx imagetools inspect "${IMAGE}:${QUARTO_VERSION}" + update-readme: runs-on: ubuntu-latest @@ -285,9 +329,9 @@ jobs: echo "Updated ${version} section in README.md" } - # Update both release and prerelease sections - update_readme_section "release" "/tmp/quarto-check-outputs/quarto-check-release/release.txt" "${BUILD_URL}" - update_readme_section "prerelease" "/tmp/quarto-check-outputs/quarto-check-prerelease/prerelease.txt" "${BUILD_URL}" + # Update both release and prerelease sections from the amd64 builds + update_readme_section "release" "/tmp/quarto-check-outputs/quarto-check-release-amd64/release.txt" "${BUILD_URL}" + update_readme_section "prerelease" "/tmp/quarto-check-outputs/quarto-check-prerelease-amd64/prerelease.txt" "${BUILD_URL}" - name: Commit and push changes env: @@ -318,14 +362,14 @@ jobs: name: Build - Quarto ${{ matrix.QUARTO_VERSION }} needs: - - "build" + - "merge" - "update-readme" if: >- (github.event_name == 'release' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') && - always() && needs.build.result == 'success' && needs.update-readme.result + always() && needs.merge.result == 'success' && needs.update-readme.result == 'success' strategy: @@ -346,10 +390,7 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up QEMU for multi-architecture builds - uses: docker/setup-qemu-action@v4 - - - name: Setup Docker buildx for multi-architecture builds + - name: Setup Docker buildx uses: docker/setup-buildx-action@v4 with: use: true From 127fd524a459f359277bb62eae87cec5f335a047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:59:59 +0200 Subject: [PATCH 6/7] ci: scope concurrency per ref and reuse cache on pull requests The concurrency group keyed on the event name, so two open pull requests cancelled each other's builds. Pull request builds also rebuilt every layer from scratch; they now pull the published per-architecture image as a cache source, while scheduled, dispatched, and release builds keep building without cache. --- .github/workflows/devcontainer.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index e8ebe36..67b289f 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -9,7 +9,7 @@ on: pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.event_name }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: @@ -90,7 +90,9 @@ jobs: imageTag: ${{ matrix.QUARTO_VERSION }}-${{ matrix.ARCH }} subFolder: .github imageName: ghcr.io/${{ github.repository }} - noCache: true + noCache: ${{ github.event_name != 'pull_request' }} + cacheFrom: ${{ github.event_name == 'pull_request' && format('ghcr.io/{0}:{1}-{2}', + github.repository, matrix.QUARTO_VERSION, matrix.ARCH) || '' }} eventFilterForPush: | release workflow_dispatch From d6e2b357c2b66adb791dc6c9e2a5fbe5de6fb1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:20:57 +0200 Subject: [PATCH 7/7] ci: keep required build status checks after per-architecture rename The repository ruleset requires status checks named 'Build - Quarto release' and 'Build - Quarto prerelease'. The build jobs now carry an architecture suffix, so those checks would never report again and every pull request would stay blocked. A build-status job reports them from the architecture build results. --- .github/workflows/devcontainer.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index e8ebe36..576c907 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -146,6 +146,33 @@ jobs: path: /tmp/quarto-check-outputs/${{ matrix.QUARTO_VERSION }}.txt retention-days: 1 + # Keeps the "Build - Quarto release" and "Build - Quarto prerelease" status + # checks required by the repository ruleset, now that the build job names + # carry an architecture suffix. It reports on the whole build matrix, so a + # failure in either architecture of either version fails both checks. + build-status: + runs-on: ubuntu-latest + name: Build - Quarto ${{ matrix.QUARTO_VERSION }} + + needs: + - "build" + + if: always() + + strategy: + matrix: + QUARTO_VERSION: + - release + - prerelease + + steps: + - name: Report architecture build results + env: + BUILD_RESULT: ${{ needs.build.result }} + run: | + echo "Architecture builds: ${BUILD_RESULT}" + [ "${BUILD_RESULT}" = "success" ] + merge: runs-on: ubuntu-latest name: Merge - Quarto ${{ matrix.QUARTO_VERSION }} manifests