From 2847268bcb7efce0e1c1dcc912f0efa7a661bd97 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sat, 24 Oct 2026 10:37:18 +0000 Subject: [PATCH 1/7] bench(null_calibration): run the Bioconductor comparators in the repo's own R environment The 2026-10-23 replication could not run glmGamPoi's quasi-likelihood F-test or edgeR's QL F-test: both are compiled Bioconductor packages and the measuring sandbox had only WebR. Those are the two rows the upstream decision is waiting on (UPSTREAM_DRAFT.md option 3), so this adds the lane that can produce them without inventing a second R environment. .github/workflows/null-calibration-comparators.yml is dispatch-only and reuses the repository's existing provisioning rather than restating it: the pinned tools script, scripts/ci/install-r-runtime.sh, and renv::restore() against renv.lock (which is where MASS and glmGamPoi come from). edgeR is deliberately not added to renv.lock - it is an outside reference for the bench and never an app dependency - so the lane installs it against the pinned Bioconductor release and each provenance file records the version. The lane commits the summary CSVs back to the dispatched branch and also emits them as annotations, so a run is readable through the checks API even where the job log is not. Fix a latent bench bug found while preparing that lane: edgeR's topTags table names its p-value column "PValue" and glmGamPoi's test_de names it "pval", but the lookup only tried lowercase "pvalue"/"p.value". Every edgeR taxon would have been reported as a fit failure - a data-looking result for a spelling mismatch. The column is now resolved case-insensitively through one helper, and a table with no p-value column is an error in calibration.R rather than a per-taxon "failed" status. Refs #193, #261 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../null-calibration-comparators.yml | 192 ++++++++++++++++++ bench/null_calibration/README.md | 12 ++ bench/null_calibration/calibration.R | 23 ++- 3 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/null-calibration-comparators.yml diff --git a/.github/workflows/null-calibration-comparators.yml b/.github/workflows/null-calibration-comparators.yml new file mode 100644 index 0000000..1b35319 --- /dev/null +++ b/.github/workflows/null-calibration-comparators.yml @@ -0,0 +1,192 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Manual-dispatch lane for the null-calibration comparators that need compiled +# Bioconductor packages (bench/null_calibration, issue #193 follow-up #261). +# +# The bench itself never runs automatically. The measurement environment it +# cannot get locally -- native R with glmGamPoi and edgeR -- is exactly the one +# this repository already provisions for CI (.github/workflows/ci.yml, "Set up +# R" + renv::restore). This lane reuses those scripts rather than restating +# them, so the R version and the package pins are the repository's, not a +# second copy that can drift. +# +# Why dispatch-only: +# * it is slow and stochastic, so it must never gate a push or a PR; +# * it installs edgeR, which is NOT in renv.lock. edgeR is an outside +# reference for the bench (calibration.R documents this), never an app +# dependency, so it is installed into the system library here and its +# version is recorded in the provenance file every cell writes. +# +# Results are returned two ways, because neither is guaranteed on every +# repository: the summary CSVs are committed back to the branch that dispatched +# the run (contents: write), and are also emitted as annotations, which the +# checks API serves even where the job log is unavailable. +name: Null-calibration comparators (manual) + +on: + workflow_dispatch: + inputs: + n_per: + description: "Samples per group, comma separated" + required: true + default: "4,12" + type: string + sims: + description: "Simulations per cell" + required: true + default: "1000" + type: string + taxa: + description: "Taxa per dataset" + required: true + default: "40" + type: string + tests: + description: "Comma-separated tests" + required: true + default: "wald_ml,lrt,ql_f,glmgampoi_ql,edger_ql,wald_trend" + type: string + commit_results: + description: "Commit the summary CSVs back to the dispatched branch" + required: true + default: true + type: boolean + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + comparators: + name: glmGamPoi and edgeR cells + runs-on: ubuntu-24.04 + timeout-minutes: 240 + env: + R_LIBS_SITE: /usr/local/lib/R/site-library:/usr/lib/R/site-library:/usr/lib/R/library + steps: + - name: Check out the dispatched branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The results commit needs the token; nothing else in this job writes. + persist-credentials: true + + # The repository's one reviewed place for tool pins (yq by tag and asset + # SHA-256, the standards and ijson-jcs checkers by exact commit). + - name: Install pinned tools + run: bash scripts/ci/fetch-statistics-tools.sh + + # YAML-POLICY Y-1: the pin is read by a parser, never by grep. + - name: Read the pinned R version + run: | + R_APT_VERSION="$(yq -r '.runtimes.r.apt_version' config/defaults/tool_versions.yml)" + BIOC_VERSION="$(yq -r '.runtimes.r.bioconductor' config/defaults/tool_versions.yml)" + echo "R_APT_VERSION=$R_APT_VERSION" >> "$GITHUB_ENV" + echo "BIOC_VERSION=$BIOC_VERSION" >> "$GITHUB_ENV" + + # Same script, same pin, same trust anchor as the CI jobs. + - name: Set up R + run: bash scripts/ci/install-r-runtime.sh full + + # MASS and glmGamPoi come from the committed lockfile; restoring it is + # what makes this the repository's environment rather than a fresh one. + - name: Restore R packages from renv.lock + run: | + set -euo pipefail + sudo Rscript --no-init-file -e 'if (!requireNamespace("renv", quietly = TRUE)) install.packages("renv", repos = "https://cloud.r-project.org", lib = .Library)' + sudo Rscript --no-init-file -e 'renv::restore(project = ".", library = .Library, prompt = FALSE)' + Rscript --no-init-file -e 'for (pkg in c("MASS", "glmGamPoi")) if (!requireNamespace(pkg, quietly = TRUE)) stop(pkg, " is missing after renv::restore")' + + # edgeR is an outside reference only and deliberately absent from + # renv.lock, so it is installed here against the pinned Bioconductor + # release. If it cannot be installed this job fails rather than silently + # running without one of the requested comparators. + - name: Install edgeR from the pinned Bioconductor release + run: | + set -euo pipefail + sudo Rscript --no-init-file -e ' + options(repos = c(CRAN = "https://cloud.r-project.org")) + if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager", lib = .Library) + BiocManager::install(version = Sys.getenv("BIOC_VERSION"), ask = FALSE, update = FALSE, lib = .Library) + BiocManager::install("edgeR", ask = FALSE, update = FALSE, lib = .Library) + if (!requireNamespace("edgeR", quietly = TRUE)) stop("edgeR failed to install") + cat("edgeR", as.character(packageVersion("edgeR")), "\n")' + + - name: Record the environment + run: | + Rscript --no-init-file -e 'cat(R.version.string, "\n"); for (p in c("MASS","glmGamPoi","edgeR")) cat(p, as.character(packageVersion(p)), "\n")' + + # --out/--results are passed explicitly: the default paths are relative to + # the repository root, which is this step's working directory. + - name: Run the comparator cells + env: + NB_N_PER: ${{ inputs.n_per }} + NB_SIMS: ${{ inputs.sims }} + NB_TAXA: ${{ inputs.taxa }} + NB_TESTS: ${{ inputs.tests }} + run: | + set -euo pipefail + Rscript --no-init-file bench/null_calibration/calibration.R \ + --shapes=original --offsets=tss \ + --n-per="$NB_N_PER" --sims="$NB_SIMS" --taxa="$NB_TAXA" \ + --tests="$NB_TESTS" --truth=null + + - name: Summarise + run: Rscript --no-init-file bench/null_calibration/summarise.R + + - name: Publish the summaries as annotations + if: always() + run: | + set -uo pipefail + for csv in bench/null_calibration/results/calibration_summary.csv \ + bench/null_calibration/results/provenance_summary.csv; do + [ -f "$csv" ] || continue + name="$(basename "$csv")" + # Annotations cap at 4096 bytes each, so the file is split and each + # chunk is one notice. %/CR are escaped as the checks API expects. + split -b 3000 -d "$csv" "$RUNNER_TEMP/annot-" + index=0 + for chunk in "$RUNNER_TEMP"/annot-*; do + index=$((index + 1)) + body="$(sed -e 's/%/%25/g' -e 's/\r/%0D/g' "$chunk" | awk '{printf "%s%%0A", $0}')" + echo "::notice title=${name} part ${index}::${body}" + done + done + + # A results commit is evidence, not a code change: it touches only the + # bench's results directory. Failure to push must not lose the run, so the + # job reports it and stays green through the annotations above. + - name: Commit the summary CSVs back to the dispatched branch + if: always() && inputs.commit_results + continue-on-error: true + env: + BRANCH: ${{ github.ref_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -uo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bench/null_calibration/results + if git diff --cached --quiet; then + echo "no results change to commit" + exit 0 + fi + git commit -m "bench(null_calibration): comparator cells from ${RUN_URL} + + Produced by the manual-dispatch lane, not by hand. Cells, seeds, executor + and package versions are in results/provenance_summary.csv." + git push origin "HEAD:${BRANCH}" + + - name: Retain the raw cells + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: null-calibration-raw + path: | + bench/null_calibration/results/raw + bench/null_calibration/results/*.csv + if-no-files-found: warn + retention-days: 30 diff --git a/bench/null_calibration/README.md b/bench/null_calibration/README.md index 33e7082..78d534b 100644 --- a/bench/null_calibration/README.md +++ b/bench/null_calibration/README.md @@ -107,6 +107,18 @@ the extracted `_FIT_R` block, and `sessionInfo()`. `summarise.R` writes and `provenance_summary.csv` — the run record, generated from the per-cell provenance files rather than assembled by hand. +### The comparators that need compiled Bioconductor packages + +`glmGamPoi` and `edgeR` cannot be installed in every environment (WebR cannot load +compiled packages at all). The repository's CI already provisions exactly that +environment — native R from the pinned CRAN repository, then `renv::restore()` +with `glmGamPoi` — so `.github/workflows/null-calibration-comparators.yml` is a +**manual-dispatch** lane that runs those cells there and commits the summary CSVs +back to the branch. It never runs on a push or a pull request: the bench is slow +and stochastic and must not gate anything. `edgeR` is not in `renv.lock` (it is +an outside reference, never an app dependency), so the lane installs it against +the pinned Bioconductor release and every provenance file records its version. + ### No native R? The WebR fallback `webr_run.mjs` runs the same `calibration.R` / `summarise.R` under WebR (R in diff --git a/bench/null_calibration/calibration.R b/bench/null_calibration/calibration.R index 1c5b524..60e85d4 100644 --- a/bench/null_calibration/calibration.R +++ b/bench/null_calibration/calibration.R @@ -325,6 +325,21 @@ fit_nb_candidates <- function(counts, offset, n_per, want) { out } +# Both package tests come back as a data.frame whose p-value column is named by +# the package: glmGamPoi's test_de returns "pval", edgeR's topTags table returns +# "PValue". A match that missed either spelling would mark every taxon "failed" +# and look like a fit problem, so the column is resolved case-insensitively and +# a missing column is an error in THIS file, not a data outcome. +p_value_column <- function(tbl, caller) { + wanted <- c("pvalue", "p.value", "pval", "p_value") + hit <- match(wanted, tolower(names(tbl))) + hit <- hit[!is.na(hit)] + if (length(hit) == 0) { + stop(caller, " returned no p-value column; got: ", paste(names(tbl), collapse = ", ")) + } + names(tbl)[hit[1]] +} + fit_glmgampoi_ql <- function(counts, offset, n_per) { g <- factor(rep(c("A", "B"), each = n_per), levels = c("A", "B")) out <- data.frame(status = rep("failed", ncol(counts)), pvalue = NA_real_) @@ -340,8 +355,8 @@ fit_glmgampoi_ql <- function(counts, offset, n_per) { glmGamPoi::test_de(gp, contrast = c(0, 1), sort_by = "none") }), error = function(e) e) if (inherits(res, "error")) return(out) - pcol <- intersect(c("pval", "pvalue", "p_value"), names(res))[1] - if (is.na(pcol) || length(res[[pcol]]) != ncol(counts)) return(out) + pcol <- p_value_column(res, "glmGamPoi::test_de") + if (length(res[[pcol]]) != ncol(counts)) return(out) p <- as.numeric(res[[pcol]]) ok <- !screens & is.finite(p) & p >= 0 & p <= 1 out$status[ok] <- "ok" @@ -368,8 +383,8 @@ fit_edger_ql <- function(counts, offset, n_per) { edgeR::topTags(qlf, n = Inf, sort.by = "none")$table }), error = function(e) e) if (inherits(res, "error")) return(out) - pcol <- intersect(c("pvalue", "p.value", "pvalue"), names(res))[1] - if (is.na(pcol) || length(res[[pcol]]) != ncol(counts)) return(out) + pcol <- p_value_column(res, "edgeR::topTags") + if (length(res[[pcol]]) != ncol(counts)) return(out) p <- as.numeric(res[[pcol]]) ok <- !screens & is.finite(p) & p >= 0 & p <= 1 out$status[ok] <- "ok" From 12178511c20fb37cd5271f2bdd9144f4b86065b9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sat, 24 Oct 2026 10:38:06 +0000 Subject: [PATCH 2/7] ci(null-calibration): trigger the comparator lane by tag as well as dispatch GitHub registers workflow_dispatch only from the default branch, so the lane cannot start a run while it is still a pull request. A push of a null-calibration-* tag runs the identical job from the tagged commit: equally deliberate, and the tag names the commit that was measured. A tag run reports through annotations and the artifact because a tag identifies no branch to commit results back to; a dispatched run also commits the summary CSVs. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../null-calibration-comparators.yml | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/.github/workflows/null-calibration-comparators.yml b/.github/workflows/null-calibration-comparators.yml index 1b35319..33e9fe1 100644 --- a/.github/workflows/null-calibration-comparators.yml +++ b/.github/workflows/null-calibration-comparators.yml @@ -18,13 +18,23 @@ # dependency, so it is installed into the system library here and its # version is recorded in the provenance file every cell writes. # -# Results are returned two ways, because neither is guaranteed on every -# repository: the summary CSVs are committed back to the branch that dispatched -# the run (contents: write), and are also emitted as annotations, which the -# checks API serves even where the job log is unavailable. +# Triggers. `workflow_dispatch` is the intended one, but GitHub only registers a +# dispatchable workflow once its file is on the default branch, so it cannot +# start a run while this lane is still a pull request. A tag push is the +# pre-registration equivalent and is equally deliberate: nothing else in this +# repository pushes `null-calibration-*` tags, so a run cannot start by accident +# and every run has a tag naming the commit it measured. +# +# Results are returned two ways, because neither is guaranteed: for a dispatched +# run the summary CSVs are committed back to the branch that dispatched it +# (contents: write); for every run they are emitted as annotations, which the +# checks API serves even where the job log or artifacts are unavailable. name: Null-calibration comparators (manual) on: + push: + tags: + - "null-calibration-*" workflow_dispatch: inputs: n_per: @@ -68,7 +78,33 @@ jobs: env: R_LIBS_SITE: /usr/local/lib/R/site-library:/usr/lib/R/site-library:/usr/lib/R/library steps: - - name: Check out the dispatched branch + # `inputs` is only populated for workflow_dispatch; a tag run falls back to + # the same defaults the dispatch form shows, and to annotations-only output. + - name: Resolve the run settings + env: + IN_N_PER: ${{ inputs.n_per }} + IN_SIMS: ${{ inputs.sims }} + IN_TAXA: ${{ inputs.taxa }} + IN_TESTS: ${{ inputs.tests }} + IN_COMMIT: ${{ inputs.commit_results }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + { + echo "NB_N_PER=${IN_N_PER:-4,12}" + echo "NB_SIMS=${IN_SIMS:-1000}" + echo "NB_TAXA=${IN_TAXA:-40}" + echo "NB_TESTS=${IN_TESTS:-wald_ml,lrt,ql_f,glmgampoi_ql,edger_ql,wald_trend}" + } >> "$GITHUB_ENV" + # A tag identifies the commit, not a branch to commit back to. + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${IN_COMMIT:-true}" != "false" ]; then + echo "TARGET_BRANCH=$REF_NAME" >> "$GITHUB_ENV" + else + echo "TARGET_BRANCH=" >> "$GITHUB_ENV" + fi + + - name: Check out the measured commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The results commit needs the token; nothing else in this job writes. @@ -122,11 +158,6 @@ jobs: # --out/--results are passed explicitly: the default paths are relative to # the repository root, which is this step's working directory. - name: Run the comparator cells - env: - NB_N_PER: ${{ inputs.n_per }} - NB_SIMS: ${{ inputs.sims }} - NB_TAXA: ${{ inputs.taxa }} - NB_TESTS: ${{ inputs.tests }} run: | set -euo pipefail Rscript --no-init-file bench/null_calibration/calibration.R \ @@ -160,13 +191,17 @@ jobs: # bench's results directory. Failure to push must not lose the run, so the # job reports it and stays green through the annotations above. - name: Commit the summary CSVs back to the dispatched branch - if: always() && inputs.commit_results + if: always() continue-on-error: true env: - BRANCH: ${{ github.ref_name }} + BRANCH: ${{ env.TARGET_BRANCH }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -uo pipefail + if [ -z "${BRANCH:-}" ]; then + echo "no target branch for this trigger; results are in the annotations and the artifact" + exit 0 + fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add bench/null_calibration/results From c048b9e3cea01be72aa6ecd403a3b62ddbd27b56 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sat, 24 Oct 2026 10:38:54 +0000 Subject: [PATCH 3/7] ci(null-calibration): reuse the renv cache the CI jobs already fill The comparator lane restored renv.lock from source on every run, which is most of its wall clock and none of its information. It now uses the same cache path, key and restore/save pair as ci.yml, so a run starts from the packages CI has already built. Saved on failure as well, since a partial restore is what the next attempt should resume from. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../null-calibration-comparators.yml | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/null-calibration-comparators.yml b/.github/workflows/null-calibration-comparators.yml index 33e9fe1..f5708dc 100644 --- a/.github/workflows/null-calibration-comparators.yml +++ b/.github/workflows/null-calibration-comparators.yml @@ -127,15 +127,44 @@ jobs: - name: Set up R run: bash scripts/ci/install-r-runtime.sh full + # The same cache ci.yml fills and reads. Without it this lane recompiles + # dada2, vegan, glmGamPoi and their dependencies from source on every run, + # which is most of the wall clock and none of the information. + - name: Restore the renv cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: /opt/renv-cache + key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }} + restore-keys: | + renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}- + renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}- + # MASS and glmGamPoi come from the committed lockfile; restoring it is # what makes this the repository's environment rather than a fresh one. - name: Restore R packages from renv.lock + env: + RENV_PATHS_CACHE: /opt/renv-cache run: | set -euo pipefail - sudo Rscript --no-init-file -e 'if (!requireNamespace("renv", quietly = TRUE)) install.packages("renv", repos = "https://cloud.r-project.org", lib = .Library)' - sudo Rscript --no-init-file -e 'renv::restore(project = ".", library = .Library, prompt = FALSE)' + sudo mkdir -p /opt/renv-cache + sudo chmod 777 /opt/renv-cache + sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'if (!requireNamespace("renv", quietly = TRUE)) install.packages("renv", repos = "https://cloud.r-project.org", lib = .Library)' + sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project = ".", library = .Library, prompt = FALSE)' Rscript --no-init-file -e 'for (pkg in c("MASS", "glmGamPoi")) if (!requireNamespace(pkg, quietly = TRUE)) stop(pkg, " is missing after renv::restore")' + # Saved on failure too: the packages built before a failure are the ones + # the next attempt should reuse. Root writes the cache, hence the chmod. + - name: Make the renv cache readable + if: always() + run: sudo chmod -R a+rX /opt/renv-cache || true + + - name: Save the renv cache + if: always() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: /opt/renv-cache + key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }} + # edgeR is an outside reference only and deliberately absent from # renv.lock, so it is installed here against the pinned Bioconductor # release. If it cannot be installed this job fails rather than silently From 502d0a1233c39e50c81711e7c9d2d0d1984d8cee Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:47:32 +0100 Subject: [PATCH 4/7] Update .github/workflows/null-calibration-comparators.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/null-calibration-comparators.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/null-calibration-comparators.yml b/.github/workflows/null-calibration-comparators.yml index f5708dc..209e362 100644 --- a/.github/workflows/null-calibration-comparators.yml +++ b/.github/workflows/null-calibration-comparators.yml @@ -172,7 +172,7 @@ jobs: - name: Install edgeR from the pinned Bioconductor release run: | set -euo pipefail - sudo Rscript --no-init-file -e ' + sudo env BIOC_VERSION="$BIOC_VERSION" Rscript --no-init-file -e ' options(repos = c(CRAN = "https://cloud.r-project.org")) if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager", lib = .Library) BiocManager::install(version = Sys.getenv("BIOC_VERSION"), ask = FALSE, update = FALSE, lib = .Library) From bccacdea45c07efb183cd75848ba4a2a7759ace6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:47:56 +0100 Subject: [PATCH 5/7] Update .github/workflows/null-calibration-comparators.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/null-calibration-comparators.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/null-calibration-comparators.yml b/.github/workflows/null-calibration-comparators.yml index 209e362..fc9098f 100644 --- a/.github/workflows/null-calibration-comparators.yml +++ b/.github/workflows/null-calibration-comparators.yml @@ -207,9 +207,10 @@ jobs: name="$(basename "$csv")" # Annotations cap at 4096 bytes each, so the file is split and each # chunk is one notice. %/CR are escaped as the checks API expects. - split -b 3000 -d "$csv" "$RUNNER_TEMP/annot-" + prefix="$RUNNER_TEMP/annot-${name}-" + split -b 3000 -d "$csv" "$prefix" index=0 - for chunk in "$RUNNER_TEMP"/annot-*; do + for chunk in "$prefix"*; do index=$((index + 1)) body="$(sed -e 's/%/%25/g' -e 's/\r/%0D/g' "$chunk" | awk '{printf "%s%%0A", $0}')" echo "::notice title=${name} part ${index}::${body}" From 3e581ddf65eab869dd725fde16de4702443c7b2d Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:49:38 +0000 Subject: [PATCH 6/7] docs(null-calibration): clarify WebR package support and comparator workflow triggers --- bench/null_calibration/README.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bench/null_calibration/README.md b/bench/null_calibration/README.md index 78d534b..f0b8925 100644 --- a/bench/null_calibration/README.md +++ b/bench/null_calibration/README.md @@ -109,13 +109,16 @@ provenance files rather than assembled by hand. ### The comparators that need compiled Bioconductor packages -`glmGamPoi` and `edgeR` cannot be installed in every environment (WebR cannot load -compiled packages at all). The repository's CI already provisions exactly that -environment — native R from the pinned CRAN repository, then `renv::restore()` -with `glmGamPoi` — so `.github/workflows/null-calibration-comparators.yml` is a -**manual-dispatch** lane that runs those cells there and commits the summary CSVs -back to the branch. It never runs on a push or a pull request: the bench is slow -and stochastic and must not gate anything. `edgeR` is not in `renv.lock` (it is +`glmGamPoi` and `edgeR` cannot be installed in every environment. WebR can load +packages compiled for WebAssembly, but cannot load ordinary native R binaries. +The repository's CI already provisions native R from the pinned CRAN repository, +then `renv::restore()` with `glmGamPoi` — so +`.github/workflows/null-calibration-comparators.yml` is a +**manual-dispatch** lane that runs those cells there and, by default, commits the +summary CSVs back to the dispatched branch. Pushing a `null-calibration-*` tag +also triggers it, with results emitted as annotations and an artifact. Ordinary +branch pushes and pull requests do not trigger it: the bench is slow and +stochastic and must not gate anything. `edgeR` is not in `renv.lock` (it is an outside reference, never an app dependency), so the lane installs it against the pinned Bioconductor release and every provenance file records its version. @@ -126,8 +129,9 @@ WebAssembly) for environments with Node but no R. `glm.nb` is pure R (MASS 7.3-65's `negbin.R` makes no `.C`/`.Call`/`.Fortran` call), so sourcing that one file provides genuine MASS code; `calibration.R` detects this mode, strips the block's `MASS::` prefixes as a single mechanical substitution, and -records the fact in every provenance file. glmGamPoi and edgeR are compiled -Bioconductor packages and cannot run this way — their tests skip loudly. +records the fact in every provenance file. This fallback does not provision +WebAssembly builds of glmGamPoi or edgeR; their tests skip loudly when the +packages are unavailable. Setup and usage are in the header of `webr_run.mjs`. Prefer native Rscript whenever it exists. From 327c333fd81d61d776aa29d5a99d811473065915 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sat, 24 Oct 2026 10:55:29 +0000 Subject: [PATCH 7/7] ci(null-calibration): annotate failures and pin the edgeR release explicitly The first tag run got through renv::restore, so glmGamPoi was installed, and then failed at edgeR with nothing readable from here: this integration can read checks annotations but not the job log. - the edgeR step ran under plain 'sudo', which resets the environment, so BIOC_VERSION was empty and the release the pin names was never requested. It is now passed with 'sudo env BIOC_VERSION=...'; the release is also spelled out in the repository URL rather than negotiated by BiocManager, so the release asked for is the release installed; - the restore, the edgeR install, the cell loop and the summary run through scripts/ci/run-with-log.sh, the repository's own helper, so a failure is annotated with its log tail instead of being invisible from the checks API; - R and package versions are recorded even when an earlier step failed. The annotation-splitting fix and the sudo/BIOC_VERSION diagnosis came from CodeRabbit's commits on this branch (502d0a1, bccacde); this commit keeps both. Refs #261 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../null-calibration-comparators.yml | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/workflows/null-calibration-comparators.yml b/.github/workflows/null-calibration-comparators.yml index fc9098f..7cea561 100644 --- a/.github/workflows/null-calibration-comparators.yml +++ b/.github/workflows/null-calibration-comparators.yml @@ -169,33 +169,69 @@ jobs: # renv.lock, so it is installed here against the pinned Bioconductor # release. If it cannot be installed this job fails rather than silently # running without one of the requested comparators. + # edgeR is an outside reference only and deliberately absent from + # renv.lock, so it is installed here against the pinned Bioconductor + # release. Explicit repository URLs are used rather than BiocManager's + # version negotiation: the release the pin names is the release asked for, + # with no upgrade path that could quietly choose another. Its dependencies + # (limma, locfit) are already present from renv.lock. + # + # `sudo env VAR=...` rather than `sudo Rscript`: sudo resets the + # environment, and an empty BIOC_VERSION silently asks for no release at + # all. - name: Install edgeR from the pinned Bioconductor release + env: + RUN_LOG_TITLE: edgeR install run: | set -euo pipefail - sudo env BIOC_VERSION="$BIOC_VERSION" Rscript --no-init-file -e ' - options(repos = c(CRAN = "https://cloud.r-project.org")) - if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager", lib = .Library) - BiocManager::install(version = Sys.getenv("BIOC_VERSION"), ask = FALSE, update = FALSE, lib = .Library) - BiocManager::install("edgeR", ask = FALSE, update = FALSE, lib = .Library) - if (!requireNamespace("edgeR", quietly = TRUE)) stop("edgeR failed to install") - cat("edgeR", as.character(packageVersion("edgeR")), "\n")' + bash scripts/ci/run-with-log.sh "$RUNNER_TEMP/edger-install.log" -- \ + sudo env BIOC_VERSION="$BIOC_VERSION" R_LIBS_SITE="$R_LIBS_SITE" \ + Rscript --no-init-file -e ' + options(Ncpus = 2, timeout = 600) + stopifnot(nzchar(Sys.getenv("BIOC_VERSION"))) + repo_url <- function(kind) sprintf("https://bioconductor.org/packages/%s/%s", + Sys.getenv("BIOC_VERSION"), kind) + repos <- c(repo_url("bioc"), repo_url("data/annotation"), + CRAN = "https://cloud.r-project.org") + # A bare name pins nothing, so the release is named explicitly in + # the repository URL and the result is reported, not assumed. + if (!requireNamespace("edgeR", quietly = TRUE)) { + install.packages("edgeR", repos = repos, lib = .Library, + dependencies = c("Depends", "Imports", "LinkingTo")) + } + if (!requireNamespace("edgeR", quietly = TRUE)) stop("edgeR failed to install") + cat("edgeR", as.character(packageVersion("edgeR")), "\n")' - name: Record the environment + if: always() run: | - Rscript --no-init-file -e 'cat(R.version.string, "\n"); for (p in c("MASS","glmGamPoi","edgeR")) cat(p, as.character(packageVersion(p)), "\n")' + Rscript --no-init-file -e ' + cat(R.version.string, "\n") + for (p in c("MASS", "glmGamPoi", "edgeR", "limma")) { + v <- tryCatch(as.character(packageVersion(p)), error = function(e) "absent") + cat(p, v, "\n") + }' # --out/--results are passed explicitly: the default paths are relative to # the repository root, which is this step's working directory. - name: Run the comparator cells + env: + RUN_LOG_TITLE: comparator cells run: | set -euo pipefail - Rscript --no-init-file bench/null_calibration/calibration.R \ + bash scripts/ci/run-with-log.sh "$RUNNER_TEMP/calibration.log" -- \ + Rscript --no-init-file bench/null_calibration/calibration.R \ --shapes=original --offsets=tss \ --n-per="$NB_N_PER" --sims="$NB_SIMS" --taxa="$NB_TAXA" \ --tests="$NB_TESTS" --truth=null - name: Summarise - run: Rscript --no-init-file bench/null_calibration/summarise.R + env: + RUN_LOG_TITLE: summarise + run: | + set -euo pipefail + bash scripts/ci/run-with-log.sh "$RUNNER_TEMP/summarise.log" -- \ + Rscript --no-init-file bench/null_calibration/summarise.R - name: Publish the summaries as annotations if: always()