diff --git a/.github/workflows/ASan.yml b/.github/workflows/ASan.yml index 7c564c2f..507f46b9 100644 --- a/.github/workflows/ASan.yml +++ b/.github/workflows/ASan.yml @@ -1,4 +1,7 @@ # Address Sanitizer: Replicate CRAN's gcc-ASAN 'Additional Test' +# Uses the r-hub gcc-asan container (R-devel built with ASAN/UBSAN). +# Additional_repositories in DESCRIPTION (https://ms609.github.io/packages/) is +# read automatically by ms609/actions/asan, so TreeDistData needs no extra input. on: workflow_dispatch: push: @@ -24,14 +27,24 @@ on: name: gcc-ASAN +permissions: + contents: read + jobs: mem-check: - runs-on: ubuntu-24.04 # Update RSPM when increasing + # The r-hub gcc-asan image has no arm64 variant: stay on x86. + runs-on: ubuntu-latest + timeout-minutes: 120 container: - image: rocker/r-devel-san + image: ghcr.io/r-hub/containers/gcc-asan:latest name: AddressSanitizer ${{ matrix.config.test }} + # Each leg runs for up to an hour: a superseding push cancels the stale one. + concurrency: + group: ${{ github.workflow }}-${{ matrix.config.test }}-${{ github.ref }} + cancel-in-progress: true + strategy: fail-fast: false matrix: @@ -41,36 +54,14 @@ jobs: - {test: 'vignettes'} env: - R_REMOTES_NO_ERRORS_FROM_WARNINGS: true - _R_CHECK_FORCE_SUGGESTS_: false - RSPM: https://packagemanager.rstudio.com/cran/__linux__/noble/latest - USING_ASAN: true - STRINGI_DISABLE_PKG_CONFIG: true - BIOCONDUCTOR_USE_CONTAINER_REPOSITORY: FALSE # For stringi GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - FS_USE_BUNDLED_LIBUV: 1 - ASAN_OPTIONS: detect_container_overflow=1:verify_asan_link_order=0 steps: - - uses: actions/checkout@v6 - - - name: Install system dependencies - run: | - apt-get update && apt-get install -y --no-install-recommends \ - libxml2-dev \ - libssl-dev \ - libcurl4-openssl-dev - - - name: Install R package dependencies - run: | - Rscript -e 'options(repos = c(CRAN = "https://cloud.r-project.org", ms609 = "https://ms609.github.io/packages/"))' \ - -e 'install.packages(c("remotes", "testthat"))' \ - -e 'remotes::install_github("ms609/TreeDistData")' \ - -e 'remotes::install_deps(dependencies = TRUE)' \ - -e 'remotes::install_local(build_opts = c("--no-build-vignettes", "--no-manual", "--no-resave-data"))' - -e 'remotes::install_local()' - - - name: ASAN - memcheck ${{ matrix.config.test }} - run: | - sudo sysctl -w vm.mmap_rnd_bits=16 - Rscript memcheck/${{ matrix.config.test }}.R + - uses: ms609/actions/asan@main + with: + test: ${{ matrix.config.test }} + token: ${{ secrets.GITHUB_TOKEN }} + # Rogue hard-imports Rfast, a ~30-min source build under ASAN that + # adds no coverage to TreeDist's own compiled code. Its only use (the + # compare-treesets vignette) is requireNamespace()-guarded. + exclude-packages: Rogue diff --git a/.github/workflows/R-CMD-check.yml b/.github/workflows/R-CMD-check.yml index d31c33b1..002b0a85 100644 --- a/.github/workflows/R-CMD-check.yml +++ b/.github/workflows/R-CMD-check.yml @@ -5,7 +5,9 @@ on: - cron: "24 06 * * 1" workflow_dispatch: push: - branches: ["*"] + branches: + - main + - master paths-ignore: - "Meta**" - "AGENT**" @@ -25,7 +27,7 @@ on: - "**.R[dD]ata" - "**.Rpro*" pull_request: - branches: ["*"] + branches: ["**"] paths-ignore: - "Meta**" - "AGENT**" @@ -47,18 +49,70 @@ on: name: R-CMD-check +# NB: there is deliberately NO workflow-level `concurrency`. Workflow-level +# cancel-in-progress would kill the whole run (sense-check included) on every +# new push. Instead, each EXPENSIVE job carries its own per-leg concurrency +# group so superseded Windows/macOS work is cancelled, while the cheap +# `sense-check` job has no concurrency and therefore always runs to completion +# -- giving a per-commit "did this build?" signal even under rapid iteration. + +# Waves: +# codemeta -> PRs only, ~15 s; pushes a stale codemeta.json to the PR +# branch and holds back later waves. +# sense-check -> arm (cheapest), never cancelled. Fast signal. Skipped only +# when codemeta pushed (the fresh run covers the new head). +# core -> Windows (+codecov) / R 4.1 / R devel; only if sense-check passed. +# benchmark -> PR-vs-base timings; also only if sense-check passed, and only +# on pull_request / workflow_dispatch. +# full -> macOS x2 + ubuntu-24.04 (release) backstop; only once core is green. +# revdepcheck -> reverse-dependency checks; also gated on core being green. +permissions: + contents: read + issues: write # scheduled-failure notifier comments on and reopens #164 + jobs: - check-release: - runs-on: ubuntu-24.04 - name: ubuntu-24.04 (release) + # --------------------------------------------------------------------------- + # Wave 0 (PRs only): is codemeta.json current? If stale, pushes the + # regenerated file to the PR branch (as the codemeta-writer App, so the push + # starts a fresh run) and fails. See ms609/actions/update-codemeta. + # --------------------------------------------------------------------------- + codemeta: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04-arm + timeout-minutes: 5 # healthy run is ~15 s + permissions: + contents: read + outputs: + pushed: ${{ steps.codemeta.outputs.pushed }} + + steps: + - id: codemeta + uses: ms609/actions/update-codemeta@main + with: + client-id: ${{ vars.CODEMETA_APP_ID }} + private-key: ${{ secrets.CODEMETA_APP_KEY }} + + # --------------------------------------------------------------------------- + # Wave 1: cheapest leg as a fail-fast sense-check. No concurrency group, so a + # rapid follow-up push does NOT cancel it -- each commit gets its own signal. + # Waits ~15 s for codemeta, and skips if codemeta pushed an update: the + # fresh run checks the new head. Still runs if codemeta failed otherwise. + # --------------------------------------------------------------------------- + sense-check: + needs: codemeta + if: ${{ !cancelled() && needs.codemeta.outputs.pushed != 'true' }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 # healthy run is ~10 min + name: sense-check (ubuntu-24.04-arm, release) env: - _R_CHECK_CRAN_INCOMING_: true # Seemingly not set by --as-cran + _R_CHECK_CRAN_INCOMING_: ${{ github.event_name == 'pull_request' }} _R_CHECK_FORCE_SUGGESTS_: false # CRAN settings R_COMPILE_AND_INSTALL_PACKAGES: 'never' _R_CHECK_THINGS_IN_CHECK_DIR_: false R_REMOTES_STANDALONE: true R_REMOTES_NO_ERRORS_FROM_WARNINGS: true + _R_CHECK_CRAN_INCOMING_USE_ASPELL_: false RSPM: "https://packagemanager.posit.co/cran/__linux__/noble/latest" GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} @@ -70,17 +124,34 @@ jobs: run: | old_version=$(grep "Version:" DESCRIPTION | awk '{print $2}') if [[ $(echo "$old_version" | tr -cd '.' | wc -c) -eq 2 ]]; then - new_version="${old_version}.8888" - if [[ "$RUNNER_OS" == "macOS" ]]; then - sed -i "" "s/Version: .*/Version: ${new_version}/" DESCRIPTION - else - sed -i "s/Version: .*/Version: ${new_version}/" DESCRIPTION - fi + new_version="${old_version}.9999" + sed -i "s/Version: .*/Version: ${new_version}/" DESCRIPTION fi shell: bash - + + # setup-r's first act on Linux is an internal `apt-get update`, and apt + # applies no transfer timeout by default: when a mirror accepts the + # connection and then stalls, the fetch never returns and the step burns + # the job's entire 6-hour budget. Observed 2026-08-19, when + # azure.archive.ubuntu.com went dark and the archive.ubuntu.com fallback + # stalled part-way through the index fetch -- a step whose healthy cost is + # ~35 s sat at 75 minutes. Capping the timeout turns that into a retry. + - name: Make apt fail fast on a stalled mirror + shell: bash + run: | + sudo tee /etc/apt/apt.conf.d/99-ci-timeouts > /dev/null <<'EOF' + Acquire::Retries "3"; + Acquire::http::Timeout "20"; + Acquire::https::Timeout "20"; + Acquire::ftp::Timeout "20"; + EOF + - name: Set up R uses: r-lib/actions/setup-r@v2 + timeout-minutes: 15 + with: + r-version: release + extra-repositories: https://ms609.github.io/packages/ - name: Set up R dependencies uses: r-lib/actions/setup-r-dependencies@v2 @@ -94,20 +165,69 @@ jobs: - name: Check package uses: r-lib/actions/check-r-package@v2 + - name: Notify on failure + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: ¬ify | + const issue_number = 164; - check-all: + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + per_page: 3 + }); + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const recentFailureNotified = comments.some(comment => + new Date(comment.created_at) > oneHourAgo && + comment.body.includes('Scheduled workflow has failed') + ); + + if (recentFailureNotified) { + console.log("Recently notified; don't bombard"); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + body: 'Scheduled workflow has failed: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + state: 'open' + }); + } + + # --------------------------------------------------------------------------- + # Wave 2: Windows (+codecov), R 4.1, R devel. Runs only if the sense-check + # passed and codemeta.json is current (or not checked, outside PRs). + # Per-leg concurrency so a superseding push cancels in-flight legs. + # --------------------------------------------------------------------------- + core: + needs: [sense-check, codemeta] + if: >- + !cancelled() && needs.sense-check.result == 'success' && + contains(fromJSON('["success", "skipped"]'), needs.codemeta.result) runs-on: ${{ matrix.config.os }} + timeout-minutes: 60 # healthy run is ~20 min (Windows, with coverage) name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + concurrency: + group: ${{ github.workflow }}-core-${{ matrix.config.os }}-${{ matrix.config.r }}-${{ github.ref }} + cancel-in-progress: true + strategy: fail-fast: false matrix: config: - {os: windows-latest, r: "release"} - - {os: macos-15-intel, r: "release"} # Until Intel architecture retired 2027-11 - - {os: macOS-latest, r: "release"} - - {os: ubuntu-22.04, r: '4.1', rspm: "https://packagemanager.posit.co/cran/__linux__/jammy/latest"} - - {os: ubuntu-24.04-arm, r: "release", rspm: "https://packagemanager.posit.co/cran/__linux__/noble/latest"} + - {os: ubuntu-22.04, r: "4.1", rspm: "https://packagemanager.posit.co/cran/__linux__/jammy/latest"} - {os: ubuntu-24.04, r: "devel", rspm: "https://packagemanager.posit.co/cran/__linux__/noble/latest"} env: @@ -118,6 +238,7 @@ jobs: R_REMOTES_STANDALONE: true R_REMOTES_NO_ERRORS_FROM_WARNINGS: true R_REALLY_FORCE_SYMBOLS: true # Until R4.3 + _R_CHECK_CRAN_INCOMING_USE_ASPELL_: false # Set to true when can figure how to install aspell on Windows RSPM: ${{ matrix.config.rspm }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} @@ -130,17 +251,28 @@ jobs: run: | old_version=$(grep "Version:" DESCRIPTION | awk '{print $2}') if [[ $(echo "$old_version" | tr -cd '.' | wc -c) -eq 2 ]]; then - new_version="${old_version}.8888" - if [[ "$RUNNER_OS" == "macOS" ]]; then - sed -i "" "s/Version: .*/Version: ${new_version}/" DESCRIPTION - else - sed -i "s/Version: .*/Version: ${new_version}/" DESCRIPTION - fi + new_version="${old_version}.9999" + sed -i "s/Version: .*/Version: ${new_version}/" DESCRIPTION fi + echo "Current package version is now: $(grep "Version:" DESCRIPTION | awk '{print $2}')" shell: bash - + + # See sense-check: without a transfer timeout a stalled apt mirror burns + # the job's whole 6-hour budget. + - name: Make apt fail fast on a stalled mirror + if: runner.os == 'Linux' + shell: bash + run: | + sudo tee /etc/apt/apt.conf.d/99-ci-timeouts > /dev/null <<'EOF' + Acquire::Retries "3"; + Acquire::http::Timeout "20"; + Acquire::https::Timeout "20"; + Acquire::ftp::Timeout "20"; + EOF + - name: Set up R uses: r-lib/actions/setup-r@v2 + timeout-minutes: 15 with: r-version: ${{ matrix.config.r }} extra-repositories: https://ms609.github.io/packages/ @@ -155,7 +287,9 @@ jobs: needs: | check coverage - + + # Suggests whose current CRAN releases need a newer R than the 4.1 leg + # offers are skipped there rather than pinned. - name: Set up R dependencies (Non-Windows) if: runner.os != 'Windows' uses: r-lib/actions/setup-r-dependencies@v2 @@ -173,7 +307,7 @@ jobs: uwot=?ignore-before-r=4.4.0 needs: | check - + - name: Check package uses: r-lib/actions/check-r-package@v2 @@ -187,62 +321,35 @@ jobs: uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const issue_number = 164; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - per_page: 3 - }); - - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - const recentFailureNotified = comments.some(comment => - new Date(comment.created_at) > oneHourAgo && - comment.body.includes('Scheduled workflow has failed') - ); - - if (recentFailureNotified) { - console.log("Recently notified; don't bombard"); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - body: 'Scheduled workflow has failed: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - state: 'open' - }); - } - rev-dep-check: - runs-on: ubuntu-latest - needs: check-release - - name: revdepcheck - - env: - R_COMPILE_AND_INSTALL_PACKAGES: 'never' - R_REMOTES_STANDALONE: true - R_REMOTES_NO_ERRORS_FROM_WARNINGS: true - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - - steps: - - uses: ms609/actions/revdepcheck@main - with: - extra-packages: ms609/TreeDistData - + script: *notify + + # --------------------------------------------------------------------------- + # Also wave 2: the benchmark comparison, gated on sense-check for the same + # reason as `core` -- a tree that does not build cannot be timed, so four + # benchmark passes over a broken package only rediscover what the cheap leg + # already said. Never runs on push or schedule: outside a PR there is no base + # ref to compare against. + # --------------------------------------------------------------------------- benchmark: + needs: [sense-check, codemeta] + if: >- + !cancelled() && needs.sense-check.result == 'success' && + contains(fromJSON('["success", "skipped"]'), needs.codemeta.result) && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest - needs: check-release - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + # A hung setup or a wedged benchmark otherwise runs to the 6-hour default, + # holding a runner the whole time. + timeout-minutes: 45 name: benchmark + concurrency: + group: ${{ github.workflow }}-benchmark-${{ github.ref }} + cancel-in-progress: true + + permissions: + contents: read + pull-requests: write + env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} RSPM: "https://packagemanager.posit.co/cran/__linux__/noble/latest" @@ -252,7 +359,7 @@ jobs: R_REMOTES_STANDALONE: true R_REMOTES_NO_ERRORS_FROM_WARNINGS: true R_REALLY_FORCE_SYMBOLS: true - PKG_CXXFLAGS: "-O3" + PKG_CXXFLAGS: "-O3" # release-grade optimisation for the compiled kernels steps: - name: Checkout PR branch @@ -266,16 +373,35 @@ jobs: ref: ${{ github.event.pull_request.base.ref || 'main' }} path: main + # See sense-check: without a transfer timeout a stalled apt mirror burns + # the job's whole 6-hour budget. + - name: Make apt fail fast on a stalled mirror + shell: bash + run: | + sudo tee /etc/apt/apt.conf.d/99-ci-timeouts > /dev/null <<'EOF' + Acquire::Retries "3"; + Acquire::http::Timeout "20"; + Acquire::https::Timeout "20"; + Acquire::ftp::Timeout "20"; + EOF + - name: Set up R uses: r-lib/actions/setup-r@v2 + timeout-minutes: 15 with: extra-repositories: https://ms609.github.io/packages/ + # A cache of its own: the default key prefix is shared with every + # ubuntu/R-release job, so a partial restore can bring back a library + # where the package itself is installed, and benchmark-step then refuses + # to run. - name: Install R dependencies uses: r-lib/actions/setup-r-dependencies@v2 with: + working-directory: pr dependencies: '"hard"' needs: benchmark + cache-version: benchmark - name: Benchmark PR uses: ms609/actions/benchmark-step@main @@ -295,6 +421,15 @@ jobs: path: pr output: pr2 + # Both trees are measured twice, alternating, so the comparison can size + # its noise band from replicates of each: a single sample of the target + # cannot tell a real change from the runner's between-step drift. + - name: Benchmark target again + uses: ms609/actions/benchmark-step@main + with: + path: main + output: main2 + - run: dir pr-benchmark-results working-directory: pr - run: dir main-benchmark-results @@ -323,7 +458,7 @@ jobs: - name: Comment on PR if: always() && github.event_name == 'pull_request' && steps.compare_results.outputs.report != '' - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: BENCHMARK_MESSAGE: ${{ steps.compare_results.outputs.report }} with: @@ -390,3 +525,191 @@ jobs: issue_number: context.issue.number, body: newCommentBody }); + + # --------------------------------------------------------------------------- + # Wave 3 (backstop): the expensive, mostly-duplicate-failure legs. Only runs + # once the core wave is green, which in practice is "about to merge". + # macOS is ~10x runner-minutes, so keeping it off every push is the big save. + # --------------------------------------------------------------------------- + full: + needs: core + runs-on: ${{ matrix.config.os }} + timeout-minutes: 60 # macOS legs are the slow ones + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + concurrency: + group: ${{ github.workflow }}-full-${{ matrix.config.os }}-${{ matrix.config.r }}-${{ github.ref }} + cancel-in-progress: true + + strategy: + fail-fast: false + matrix: + config: + - {os: ubuntu-24.04, r: "release", rspm: "https://packagemanager.posit.co/cran/__linux__/noble/latest"} + - {os: macos-15-intel, r: "release"} # Until Intel architecture retired 2027-11 + - {os: macOS-latest, r: "release"} + + env: + _R_CHECK_CRAN_INCOMING_: ${{ github.event_name == 'pull_request' }} + _R_CHECK_FORCE_SUGGESTS_: false # CRAN settings + R_COMPILE_AND_INSTALL_PACKAGES: 'never' + _R_CHECK_THINGS_IN_CHECK_DIR_: false + R_REMOTES_STANDALONE: true + R_REMOTES_NO_ERRORS_FROM_WARNINGS: true + _R_CHECK_CRAN_INCOMING_USE_ASPELL_: false + RSPM: ${{ matrix.config.rspm }} + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout git repo + uses: actions/checkout@v6 + + - name: Temporarily bump package version + run: | + old_version=$(grep "Version:" DESCRIPTION | awk '{print $2}') + if [[ $(echo "$old_version" | tr -cd '.' | wc -c) -eq 2 ]]; then + new_version="${old_version}.9999" + if [[ "$RUNNER_OS" == "macOS" ]]; then + sed -i "" "s/Version: .*/Version: ${new_version}/" DESCRIPTION + else + sed -i "s/Version: .*/Version: ${new_version}/" DESCRIPTION + fi + fi + echo "Current package version is now: $(grep "Version:" DESCRIPTION | awk '{print $2}')" + shell: bash + + # See sense-check: without a transfer timeout a stalled apt mirror burns + # the job's whole 6-hour budget. + - name: Make apt fail fast on a stalled mirror + if: runner.os == 'Linux' + shell: bash + run: | + sudo tee /etc/apt/apt.conf.d/99-ci-timeouts > /dev/null <<'EOF' + Acquire::Retries "3"; + Acquire::http::Timeout "20"; + Acquire::https::Timeout "20"; + Acquire::ftp::Timeout "20"; + EOF + + - name: Set up R + uses: r-lib/actions/setup-r@v2 + timeout-minutes: 15 + with: + r-version: ${{ matrix.config.r }} + extra-repositories: https://ms609.github.io/packages/ + + - name: Set up R dependencies + if: runner.os != 'macOS' + uses: r-lib/actions/setup-r-dependencies@v2 + with: + cache-version: 2-${{ runner.arch }} + extra-packages: | + ms609/TreeDistData + needs: | + check + + # Temporary: CRAN's macOS binaries are now partly zstd-compressed, which + # pak cannot extract ("unknown archive type"; r-lib/pkgdepends#485, + # r-lib/pak#915). Base R's untar() reads zstd, so install through + # install.packages() on macOS, and set up the pandoc that + # setup-r-dependencies would otherwise provide. Once pak supports zstd, + # delete both macOS steps and the `if` on the pak step above. + - name: Set up pandoc (macOS, without pak) + if: runner.os == 'macOS' + uses: r-lib/actions/setup-pandoc@v2 + + - name: Set up R dependencies (macOS, without pak) + if: runner.os == 'macOS' + run: | + lib <- Sys.getenv("R_LIBS_USER") + dir.create(lib, recursive = TRUE, showWarnings = FALSE) + .libPaths(c(lib, .libPaths())) + + # CRAN's own binaries, not P3M's: P3M's macOS build of TreeDist + # (needed here by TreeDistData) references libomp's __kmpc_* + # symbols without linking libomp, so it fails to load ("symbol not + # found in flat namespace '___kmpc_dispatch_init_4'"). + options(repos = c(CRAN = "https://cloud.r-project.org", + ms609 = "https://ms609.github.io/packages/")) + + install.packages(c("remotes", "rcmdcheck")) + remotes::install_deps(dependencies = TRUE, upgrade = "never") + remotes::install_github("ms609/TreeDistData", upgrade = "never") + + # install.packages() only warns on failure: fail the step instead. + deps <- remotes::local_package_deps(dependencies = TRUE) + wanted <- c(deps, "rcmdcheck", "TreeDistData") + missing <- setdiff(wanted, rownames(installed.packages())) + if (length(missing)) { + stop("Failed to install: ", paste(missing, collapse = ", ")) + } + shell: Rscript {0} + + - name: Check package + uses: r-lib/actions/check-r-package@v2 + + - name: Notify on failure + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: *notify + + # --------------------------------------------------------------------------- + # Reverse-dependency checks: also a wave-3 backstop, gated on core being green. + # One leg per dependency type, so a slow Suggests sweep neither blocks nor + # masks the hard-dependency results. + # --------------------------------------------------------------------------- + rev-dep-check: + needs: core + runs-on: ubuntu-latest + timeout-minutes: 120 # revdepchecks are genuinely long + + name: revdepcheck (${{ matrix.config.deps }}) + + concurrency: + group: ${{ github.workflow }}-revdep-${{ matrix.config.deps }}-${{ github.ref }} + cancel-in-progress: true + + strategy: + fail-fast: false + matrix: + config: + - {deps: '"Depends"'} + - {deps: '"Suggests"'} + - {deps: '"Imports"'} + - {deps: '"LinkingTo"'} + + env: + _R_CHECK_CRAN_INCOMING_: false + _R_CHECK_FORCE_SUGGESTS_: false # CRAN settings + R_COMPILE_AND_INSTALL_PACKAGES: 'never' + _R_CHECK_THINGS_IN_CHECK_DIR_: false + R_REMOTES_STANDALONE: true + R_REMOTES_NO_ERRORS_FROM_WARNINGS: true + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + steps: + # See sense-check: without a transfer timeout a stalled apt mirror burns + # the job's whole 6-hour budget. + - name: Make apt fail fast on a stalled mirror + shell: bash + run: | + sudo tee /etc/apt/apt.conf.d/99-ci-timeouts > /dev/null <<'EOF' + Acquire::Retries "3"; + Acquire::http::Timeout "20"; + Acquire::https::Timeout "20"; + Acquire::ftp::Timeout "20"; + EOF + + - uses: ms609/actions/revdepcheck@main + with: + deps: ${{ matrix.config.deps }} + extra-packages: ms609/TreeDistData + + - name: Notify on failure + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: *notify diff --git a/.github/workflows/RcppDeepState.yml b/.github/workflows/RcppDeepState.yml index a15b6a79..1e1f354c 100644 --- a/.github/workflows/RcppDeepState.yml +++ b/.github/workflows/RcppDeepState.yml @@ -11,8 +11,23 @@ on: - 'memcheck/**' - 'tests/testthat/**.R' - 'vignettes/**.Rmd' + pull_request: + branches: + - main + - master + paths: + - '.github/workflows/RcppDeepState.yml' + - 'src/**' + - 'inst/include/**' + - 'memcheck/**' + - 'tests/testthat/**.R' + - 'vignettes/**.Rmd' name: 'RcppDeepState analysis' +permissions: + contents: read + pull-requests: write # comment: true posts results to the PR + jobs: RcppDeepState: runs-on: ubuntu-latest @@ -21,7 +36,7 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - uses: FabrizioSandri/RcppDeepState-action@main with: diff --git a/.github/workflows/codemeta.yml b/.github/workflows/codemeta.yml deleted file mode 100644 index 161215cd..00000000 --- a/.github/workflows/codemeta.yml +++ /dev/null @@ -1,19 +0,0 @@ -on: - push: - branches: - - main - - master - paths: - - 'DESCRIPTION' - - '**codemeta.yml' - -name: codemeta - -jobs: - codemeta: - runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - - steps: - - uses: ms609/actions/codemeta@main diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index de903b35..24b421ae 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -14,7 +14,7 @@ on: jobs: # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. copilot-setup-steps: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm # Set the permissions to the lowest permissions possible needed for your steps. # Copilot will be given its own token for its operations. diff --git a/.github/workflows/memcheck.yml b/.github/workflows/memcheck.yml index dbf58e22..44932b0a 100644 --- a/.github/workflows/memcheck.yml +++ b/.github/workflows/memcheck.yml @@ -28,9 +28,13 @@ on: name: mem-check +permissions: + contents: read + jobs: mem-check: - runs-on: ubuntu-24.04 + runs-on: ubuntu-24.04-arm + timeout-minutes: 180 name: valgrind ${{ matrix.config.test }} diff --git a/.github/workflows/pkgdown.yml b/.github/workflows/pkgdown.yml index 85e25ffa..454197e3 100644 --- a/.github/workflows/pkgdown.yml +++ b/.github/workflows/pkgdown.yml @@ -22,6 +22,10 @@ name: pkgdown jobs: pkgdown: runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: write env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} @@ -156,3 +160,60 @@ jobs: folder: docs branch: gh-pages clean: false + + # ── Point /dev/ at the release site once a release is deployed ── + - name: Check if release version + id: version-check + run: | + VERSION=$(grep '^Version:' DESCRIPTION | sed 's/Version: *//') + # Release = exactly 3 numeric components (e.g. 2.2.0) + # Dev = 4+ components (e.g. 2.2.0.9001) + if echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+[.\-][0-9]+$'; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "Release version: $VERSION" + else + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "Dev version: $VERSION" + fi + shell: bash + + - name: Replace dev docs with redirects + if: steps.version-check.outputs.is_release == 'true' + run: | + TMPDIR=$(mktemp -d) + git clone --single-branch --branch gh-pages \ + "https://x-access-token:${GITHUB_PAT}@github.com/${GITHUB_REPOSITORY}.git" \ + "$TMPDIR" + cd "$TMPDIR" + + git config user.email "actions@github.com" + git config user.name "GitHub Actions" + + # Remove old dev docs from git + git rm -rf dev || true + + # Redirect template: strips /dev/ from the current URL + cat > /tmp/redirect.html << 'EOF' + + + + +

Redirecting to release documentation…

+ + + EOF + + # Create a redirect stub for every HTML page in the release site + find . -name '*.html' -not -path './dev/*' -not -path './.git/*' \ + | while read -r f; do + rel="${f#./}" + mkdir -p "dev/$(dirname "$rel")" + cp /tmp/redirect.html "dev/$rel" + done + + git add dev/ + git commit -m "Replace dev/ docs with redirects to release" || true + git push origin gh-pages + + rm -rf "$TMPDIR" + shell: bash diff --git a/.github/workflows/revdepcheck.yml b/.github/workflows/revdepcheck.yml index 53a46a38..4e9fdeb7 100644 --- a/.github/workflows/revdepcheck.yml +++ b/.github/workflows/revdepcheck.yml @@ -4,6 +4,9 @@ name: rev-dep-check on: workflow_dispatch: +permissions: + contents: read + jobs: mem-check: runs-on: ubuntu-latest diff --git a/.github/workflows/rhub.yaml b/.github/workflows/rhub.yaml index e3fa9124..b10fe0af 100644 --- a/.github/workflows/rhub.yaml +++ b/.github/workflows/rhub.yaml @@ -25,6 +25,9 @@ on: description: 'Run name (optional)' type: string +permissions: + contents: read + jobs: setup: diff --git a/.github/workflows/update-csl.yml b/.github/workflows/update-csl.yml index baac81b6..24e8878b 100644 --- a/.github/workflows/update-csl.yml +++ b/.github/workflows/update-csl.yml @@ -9,9 +9,12 @@ on: paths: - '.github/workflows/update-csl.yml' +permissions: + contents: write # pushes the refreshed CSL file + jobs: update-csl: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index 76a6f441..d0d73691 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -29,6 +29,9 @@ on: description: "Comma-separated packages to build (deps resolved automatically)" default: "fastmatch" +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/DESCRIPTION b/DESCRIPTION index c23b529c..a69a6da9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -83,6 +83,7 @@ LinkingTo: RdMacros: Rdpack VignetteBuilder: knitr Config/Needs/app/optional: uwot +Config/Needs/benchmark: bench, phangorn, testthat Config/Needs/check: rcmdcheck Config/Needs/coverage: covr Config/Needs/memcheck: pkgdown, testthat diff --git a/benchmark/_compare_results.R b/benchmark/_compare_results.R index 21e0b736..2cb48c10 100644 --- a/benchmark/_compare_results.R +++ b/benchmark/_compare_results.R @@ -5,7 +5,7 @@ output <- paste0( "report< min_meaningful_diff && + median_pr < median_main - 2 * noise + is_slower <- matched && - median_pr > median_main + 2 * mad_main && - median_pr > noise_range[[2]] + abs_diff > min_meaningful_diff && + median_pr > median_main + 2 * noise report[[fn_name]] <- list( matched = matched, @@ -66,6 +86,8 @@ for (pr_file in pr_files) { median_pr = median(pr1_times), median_cf = median(pr2_times), median_main = median_main, + median_main1 = median(main1_times), + median_main2 = median(main2_times), change = percentage_change ) } @@ -73,25 +95,28 @@ for (pr_file in pr_files) { # Create a markdown-formatted message has_significant_regression <- FALSE - for (fn_name in names(report)) { - res <- report[[fn_name]] - status <- if (res$matched) { + for (i in seq_along(report)) { + fn_name <- names(report)[[i]] + res <- report[[i]] + if (is.null(res) || is.null(res$matched)) next + + status <- if (isTRUE(res$matched)) { if (res$slower) { - if (abs(percentage_change) > threshold_percent) { + if (abs(res$change) > threshold_percent) { has_significant_regression <- TRUE "\U1F7E0 Slower \U1F641" } else { "\U1F7E3 ~same" } } else if (res$faster) { - if (abs(percentage_change) > threshold_percent) { + if (abs(res$change) > threshold_percent) { "\U1F7E2 Faster!" } else { "\U1F7E3 ~same" } } else { "\U26AA NSD" - } + } } else { "\U1F7E4 ?Mismatch" } @@ -100,19 +125,20 @@ for (pr_file in pr_files) { message <- paste0( "| `", fn_name, "` | ", status, " | ", - bold, round(res$change, 2), "%", bold, " | ", - signif(res$median_main * 1e3, 3), " \u2192
", + bold, round(res$change, 2), "%", bold, " | ", + signif(res$median_main1 * 1e3, 3), ", ", + signif(res$median_main2 * 1e3, 3), " \u2192
", signif(res$median_pr * 1e3, 3), ", ", signif(res$median_cf * 1e3, 3), " |\n" ) + + cat(message) + output <- paste0(output, message) } if (has_significant_regression) { regressions <- TRUE } - - cat(message) - output <- paste0(output, message) } cat(paste0(output, "\nEOF"), file = Sys.getenv("GITHUB_OUTPUT"), append = TRUE) diff --git a/codemeta.json b/codemeta.json index 2c7972c7..5af80745 100644 --- a/codemeta.json +++ b/codemeta.json @@ -14,7 +14,6 @@ "name": "R", "url": "https://r-project.org" }, - "runtimePlatform": "R version 4.6.1 (2026-06-24)", "provider": { "@id": "https://cran.r-project.org", "@type": "Organization", @@ -397,19 +396,19 @@ "sameAs": "https://CRAN.R-project.org/package=withr" } ], - "softwareRequirements": { - "1": { + "softwareRequirements": [ + { "@type": "SoftwareApplication", "identifier": "R", "name": "R", "version": ">= 4.0" }, - "2": { + { "@type": "SoftwareApplication", "identifier": "stats", "name": "stats" }, - "3": { + { "@type": "SoftwareApplication", "identifier": "ape", "name": "ape", @@ -422,7 +421,7 @@ }, "sameAs": "https://CRAN.R-project.org/package=ape" }, - "4": { + { "@type": "SoftwareApplication", "identifier": "cli", "name": "cli", @@ -435,7 +434,7 @@ }, "sameAs": "https://CRAN.R-project.org/package=cli" }, - "5": { + { "@type": "SoftwareApplication", "identifier": "Rdpack", "name": "Rdpack", @@ -448,7 +447,7 @@ }, "sameAs": "https://CRAN.R-project.org/package=Rdpack" }, - "6": { + { "@type": "SoftwareApplication", "identifier": "shiny", "name": "shiny", @@ -460,7 +459,7 @@ }, "sameAs": "https://CRAN.R-project.org/package=shiny" }, - "7": { + { "@type": "SoftwareApplication", "identifier": "shinyjs", "name": "shinyjs", @@ -472,7 +471,7 @@ }, "sameAs": "https://CRAN.R-project.org/package=shinyjs" }, - "8": { + { "@type": "SoftwareApplication", "identifier": "TreeTools", "name": "TreeTools", @@ -485,10 +484,12 @@ }, "sameAs": "https://CRAN.R-project.org/package=TreeTools" }, - "SystemRequirements": "C++17, pandoc-citeproc" - }, - "keywords": ["phylogenetics", "tree-distance"], - "fileSize": "2086.252KB", + "C++17, pandoc-citeproc" + ], + "keywords": [ + "phylogenetics", + "tree-distance" + ], "citation": [ { "@type": "ScholarlyArticle", @@ -496,7 +497,10 @@ "author": [ { "@type": "Person", - "givenName": ["Martin", "R."], + "givenName": [ + "Martin", + "R." + ], "familyName": "Smith", "email": "martin.smith@durham.ac.uk" } @@ -511,7 +515,10 @@ "issueNumber": "20", "datePublished": "2020", "isPartOf": { - "@type": ["PublicationVolume", "Periodical"], + "@type": [ + "PublicationVolume", + "Periodical" + ], "volumeNumber": "36", "name": "Bioinformatics" } @@ -523,7 +530,10 @@ "author": [ { "@type": "Person", - "givenName": ["Martin", "R."], + "givenName": [ + "Martin", + "R." + ], "familyName": "Smith", "email": "martin.smith@durham.ac.uk" } @@ -538,7 +548,10 @@ "issueNumber": "5", "datePublished": "2022", "isPartOf": { - "@type": ["PublicationVolume", "Periodical"], + "@type": [ + "PublicationVolume", + "Periodical" + ], "volumeNumber": "71", "name": "Systematic Biology" } @@ -550,7 +563,10 @@ "author": [ { "@type": "Person", - "givenName": ["Martin", "R."], + "givenName": [ + "Martin", + "R." + ], "familyName": "Smith", "email": "martin.smith@durham.ac.uk" } @@ -563,7 +579,10 @@ "@type": "PublicationIssue", "datePublished": "2020", "isPartOf": { - "@type": ["PublicationVolume", "Periodical"], + "@type": [ + "PublicationVolume", + "Periodical" + ], "name": "Comprehensive R Archive Network" } } diff --git a/tests/testthat/test-tree_distance_spr.R b/tests/testthat/test-tree_distance_spr.R index ff0a3ee8..1a2ffc9d 100644 --- a/tests/testthat/test-tree_distance_spr.R +++ b/tests/testthat/test-tree_distance_spr.R @@ -177,7 +177,13 @@ test_that("SPR shortcuts okay - larger trees", { test_that("SPR calculated correctly", { library("TreeTools", quietly = TRUE) + # Compares against TBRDist's exact uSPR distance, when available (Suggests); + # the fixed-value expectations below run regardless. + haveTBRDist <- requireNamespace("TBRDist", quietly = TRUE) expect_exact <- function(x, y, method = "rogue") { + if (!haveTBRDist) { + return(invisible()) + } if (is.character(x)) x <- Tree(x) if (is.character(y)) y <- Tree(y) expect_equal( @@ -204,9 +210,11 @@ test_that("SPR calculated correctly", { expect_exact("(a,(d,(b,(c,X))));", "(a,((b,c),(X,d)));", "rogue") # distance = 1 expect_exact("((((b,c),d),e),a);", "(a,(b,((e,c),d)));", "rogue") - expect_failure( - expect_exact("((((b,c),d),e),a);", "(a,(b,((e,c),d)));", "deo") - ) + if (haveTBRDist) { + expect_failure( + expect_exact("((((b,c),d),e),a);", "(a,(b,((e,c),d)));", "deo") + ) + } # Passes with ami, joint, vi # Fails with viNorm - should tiebreaker be non-normalized?