From 5ce95eb25c6ed4dddf314e59c419dc4f04432fff Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Wed, 16 Sep 2026 16:00:46 -0500 Subject: [PATCH 1/2] Support macOS 27 (Golden Gate) macrtools was hard-broken on macOS 27. is_macos_r_supported() compared against an exclusive upper bound of "27.0", so sw_vers reporting "27.0" returned FALSE and aborted macos_rtools_install(), gfortran_install() and openmp_install(). This is the third occurrence of the same off-by-one: #27 shipped it for Tahoe, #28 fixed it, and the fixed value then went stale one release later. Centralize the window behind minimum_supported_macos_version() and first_unsupported_macos_version(), naming the ceiling for the first version it rejects rather than the newest it accepts, and render the advertised range from that bound via macos_support_range() so the abort and startup messages cannot drift from the check they describe. Add is_macos_golden_gate() for 27.x. Also fix version comparison for .0 releases. sw_vers emits only two components for a .0 release, and compareVersion() ranks a shorter string below an otherwise-equal longer one, so every .0 release landed in the previous release's range: "12.0" read as Big Sur, "11.0" matched no predicate at all, and "10.13" was rejected by an error message that named 10.13 as supported. pad_version() now normalizes both the version and the bounds before comparison. The suite previously passed 136/136 on a macOS 27 machine the package refused to run on, because the upper bound had never been tested. Add boundary, contiguity and .0-form coverage, verified by mutation. --- DESCRIPTION | 4 +- NEWS.md | 58 ++++++++++++++ R/assertions.R | 3 +- R/openmp.R | 16 +++- R/recipes.R | 13 ++- R/system.R | 118 ++++++++++++++++++++++++++- R/zzz.R | 4 +- man/is_macos_golden_gate.Rd | 20 +++++ man/is_macos_tahoe.Rd | 4 +- man/macos_support_range.Rd | 20 +++++ man/macos_version_in_range.Rd | 5 ++ man/newest_supported_macos_name.Rd | 15 ++++ man/pad_version.Rd | 29 +++++++ man/recipes_binary_install.Rd | 13 ++- man/supported_macos_version.Rd | 31 ++++++++ tests/testthat/test-assertions.R | 17 ++++ tests/testthat/test-system.R | 124 +++++++++++++++++++++++++++++ 17 files changed, 476 insertions(+), 18 deletions(-) create mode 100644 man/is_macos_golden_gate.Rd create mode 100644 man/macos_support_range.Rd create mode 100644 man/newest_supported_macos_name.Rd create mode 100644 man/pad_version.Rd create mode 100644 man/supported_macos_version.Rd diff --git a/DESCRIPTION b/DESCRIPTION index fefca52..25272b3 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: macrtools Title: macOS Rtools package -Version: 0.0.7 +Version: 0.0.8 Authors@R: c( person(given = "James Joseph", family = "Balamuta", @@ -30,6 +30,6 @@ URL: https://mac.thecoatlessprofessor.com/macrtools/ Suggests: rstudioapi, mockery, - testthat (>= 3.0.0) + testthat (>= 3.1.7) Config/testthat/edition: 3 Config/roxygen2/version: 8.0.0 diff --git a/NEWS.md b/NEWS.md index 6abab21..29f61e7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,61 @@ +# macrtools 0.0.8 + +## Features + +- Added support for macOS Golden Gate (27.x). + +## Bug Fixes + +- Fixed the macOS version check so that macOS 27 is recognized as supported. + `is_macos_r_supported()` compares against an *exclusive* upper bound, so the + previous value of `"27.0"` rejected every macOS 27 release, including 27.0 + itself. On macOS 27 this aborted `macos_rtools_install()`, `gfortran_install()` + and `openmp_install()`, and printed an "unsupported" warning on attach. + This is the same off-by-one that affected Tahoe + ([#28](https://github.com/coatless-mac/macrtools/issues/28)). + +- Fixed macOS version comparisons for `.0` releases. `sw_vers -productVersion` + reports only two components for a `.0` release (macOS 27.0 reports `"27.0"`, + never `"27.0.0"`), and `utils::compareVersion()` ranks a shorter string below + an otherwise-equal longer one. Comparing those against three-component bounds + placed every `.0` release in the *previous* release's range: `"12.0"` was + reported as Big Sur rather than Monterey, `"11.0"` matched nothing at all, and + `"10.13"` was rejected as unsupported by an error message that named 10.13 as + supported. Versions and bounds are now padded to three components before + comparison. + +## Internal + +- Centralized the macOS support window behind `minimum_supported_macos_version()` + and `first_unsupported_macos_version()`, mirroring the R version window added + in 0.0.7. The ceiling is now named for what it is, the first *rejected* + version, which is the distinction both previous off-by-one bugs got wrong. +- The supported range shown by `assert_macos_supported()` and the startup + message is now rendered by a single `macos_support_range()` helper that + derives the version number from the bound. Previously the range was written + out as a literal in two files, with different wording, and neither was tested. +- Added upper-bound tests for `is_macos_r_supported()`. The suite previously + exercised only the lower bound, which is why both off-by-one bugs shipped + without a test failure. Added a test asserting that exactly one named + `is_macos_*()` predicate matches any given release, so a new release cannot be + swallowed by its predecessor's range. +- Added `is_macos_golden_gate()` for 27.x. +- Added `pad_version()` to normalize version strings before comparison, and + extended the predicate test to cover the two-component `.0` form that + `sw_vers` actually emits. +- Corrected the `recipes_binary_install()` documentation, which stated that the + `darwin` repository is chosen by comparing against the macOS version. It is + chosen by comparing against the Darwin kernel version, and the two are no + longer a fixed offset apart: Darwin stayed on 25 for macOS 26, then realigned + to 27 for macOS 27, skipping 26. +- Noted in `R/openmp.R` that Apple renumbered Apple clang from 17.0.0 + (`clang-1700.x`) to 21.0.0 (`clang-2100.x`) at Xcode 26.4, so Xcode 26.4 and + newer extrapolate onto the LLVM OpenMP 19.1.5 row. Upstream documents that + runtime only through Xcode 26.3, but 19.1.5 remains the newest build published + on mac.r-project.org, so the selection is unchanged and still correct. +- Bumped the declared `testthat` minimum to 3.1.7, the version that introduced + `local_mocked_bindings()`, which the suite already relies on throughout. + # macrtools 0.0.7 ## Features diff --git a/R/assertions.R b/R/assertions.R index 643eca9..c5b7bfb 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -39,9 +39,10 @@ assert_macos_supported <- function(call = caller_env()) { if (!is_macos_r_supported()) { mac_version <- shell_mac_version() + supported_range <- macos_support_range() cli::cli_abort(c( "{.pkg macrtools}: Your macOS version {.val {mac_version}} is not supported.", - "{.pkg macrtools}: Supported versions: macOS High Sierra (10.13) through macOS Tahoe (26.x).", + "{.pkg macrtools}: Supported versions: {supported_range}.", "i" = "Please upgrade your macOS to a supported version or use an alternative method to install development tools." ), call = call) diff --git a/R/openmp.R b/R/openmp.R index 9d7f5a5..7aa55ba 100644 --- a/R/openmp.R +++ b/R/openmp.R @@ -359,12 +359,22 @@ get_openmp_url_for_xcode <- function() { clang_info <- get_apple_clang_version() clang_version <- clang_info$build_number - # Version mapping based on the documentation - # Using the Release versions for stability + # Version mapping based on the documentation at + # https://mac.r-project.org/openmp/, using the Release versions for + # stability. The table is descending and the first row whose min_clang the + # detected build meets is selected, so a newer-than-documented Apple clang + # resolves to the newest known runtime rather than failing. + # + # Upstream documents 19.1.5 only through Xcode 26.3 (Apple clang 1700.x). + # Apple then renumbered Apple clang from 17.0.0 (clang-1700.x) to 21.0.0 + # (clang-2100.x) at Xcode 26.4, so Xcode 26.4 and newer, including Xcode 27 + # (Apple clang 2100.3.x), extrapolate onto that row. 19.1.5 is still the + # newest runtime published upstream, so it remains the best available + # choice. Revisit when mac.r-project.org publishes a newer build. openmp_mapping <- base::data.frame( min_clang = c(1700, 1600, 1500, 1403, 1400, 1316, 1300, 1205, 1200, 1103, 1100, 1001), filename = c( - "openmp-19.1.5-darwin20-Release.tar.gz", # Xcode 16.3-26.x (Apple clang 1700.x and up) + "openmp-19.1.5-darwin20-Release.tar.gz", # Xcode 16.3 and up (Apple clang 1700.x and up) "openmp-17.0.6-darwin20-Release.tar.gz", # Xcode 16.0-16.2 (Apple clang 1600.x) "openmp-16.0.4-darwin20-Release.tar.gz", # Xcode 15.x (Apple clang 1500.x) "openmp-15.0.7-darwin20-Release.tar.gz", # Xcode 14.3.x (Apple clang 1403.x) diff --git a/R/recipes.R b/R/recipes.R index 207b535..68b0d2c 100644 --- a/R/recipes.R +++ b/R/recipes.R @@ -48,9 +48,16 @@ #' | [darwin23/arm64](https://mac.r-project.org/bin/darwin23/arm64) | /opt/R/arm64 | macOS 14 (Sonoma), Apple Silicon (arm64), used by R 4.6 | #' #' The correct repository is detected automatically: the highest `darwin` -#' version less than or equal to your macOS is selected for your architecture. -#' R 4.6 on Apple Silicon (macOS 14+) therefore resolves to `darwin23/arm64`, -#' which installs to the same `/opt/R/arm64` prefix as `darwin20/arm64`. +#' version less than or equal to your *Darwin kernel* version (`uname -r`) is +#' selected for your architecture. R 4.6 on Apple Silicon (macOS 14+) therefore +#' resolves to `darwin23/arm64`, which installs to the same `/opt/R/arm64` +#' prefix as `darwin20/arm64`. +#' +#' Note that the Darwin version is not the macOS version, and the two are no +#' longer a fixed offset apart. Darwin tracked `macOS + 9` through macOS 15 +#' (Darwin 24), stayed on 25 for macOS 26 (Tahoe), then realigned to match the +#' marketing number for macOS 27 (Darwin 27), skipping Darwin 26 entirely. +#' Always read the Darwin version from `uname -r` rather than deriving it. #' #' @section Differences: #' The official implementation uses `quiet` as a parameter to suppress output diff --git a/R/system.R b/R/system.R index 87c653b..a91cf42 100644 --- a/R/system.R +++ b/R/system.R @@ -64,14 +64,106 @@ exec_text <- function(command, args, fallback = "Unknown") { ) } +#' Pad a Version String to major.minor.patch +#' +#' @details +#' `sw_vers -productVersion` reports only two components for a `.0` release: +#' macOS 27.0 reports `"27.0"`, never `"27.0.0"`. [utils::compareVersion()] +#' ranks a shorter string *below* an otherwise-equal longer one, so +#' `compareVersion("10.13", "10.13.0")` is `-1`. Comparing an unpadded +#' two-component version against three-component bounds therefore places every +#' `.0` release in the *previous* release's range. +#' +#' Padding both the version and the bounds to three components removes that +#' ambiguity. Versions with more than three components are left untouched. +#' +#' @param version A version string such as `"27"`, `"27.0"` or `"27.0.1"`. +#' @return The version padded to at least three components. +#' @keywords internal +pad_version <- function(version) { + parts <- base::strsplit(base::trimws(version), ".", fixed = TRUE)[[1]] + parts <- parts[base::nzchar(parts)] + + if (base::length(parts) == 0L) { + return(version) + } + + base::paste( + c(parts, base::rep("0", base::max(0L, 3L - base::length(parts)))), + collapse = "." + ) +} + #' Check if the macOS Version Falls in a Range #' +#' @details +#' The running version and both bounds are padded by [pad_version()] before +#' comparison, so a bound may be written with two or three components +#' interchangeably. +#' #' @param lower Lower bound for the macOS version (inclusive). #' @param upper Upper bound for the macOS version (exclusive). #' @return TRUE if the running macOS version is in `[lower, upper)`. #' @keywords internal macos_version_in_range <- function(lower, upper) { - version_between(shell_mac_version(), lower, upper) + version_between( + pad_version(shell_mac_version()), + pad_version(lower), + pad_version(upper) + ) +} + +#' Supported macOS Version Window +#' +#' Single source of truth for the macOS versions whose toolchain `macrtools` +#' supports. +#' +#' @details +#' [first_unsupported_macos_version()] is an **exclusive** bound: it names the +#' first macOS version that is *rejected*, not the newest one that is accepted. +#' Supporting a new macOS release therefore means setting it to the *following* +#' major version, e.g. supporting macOS 27 requires `"28.0"`. +#' +#' Naming the newest supported release here instead silently excludes that +#' entire release. That mistake shipped twice: once for Tahoe +#' ([#28](https://github.com/coatless-mac/macrtools/issues/28)) and again for +#' macOS 27. +#' +#' @return A macOS version string. +#' @keywords internal +#' @name supported_macos_version +minimum_supported_macos_version <- function() "10.13.0" + +#' @rdname supported_macos_version +#' @keywords internal +first_unsupported_macos_version <- function() "28.0" + +#' Marketing Name of the Newest Supported macOS Release +#' +#' @return The marketing name of the newest supported macOS release. +#' @keywords internal +newest_supported_macos_name <- function() "Golden Gate" + +#' Human-readable macOS Support Window +#' +#' Renders the supported macOS range for user-facing messages. +#' +#' @details +#' The upper major version is derived from [first_unsupported_macos_version()] +#' rather than written out, so the advertised range cannot disagree with the +#' check it describes. +#' +#' @return A string naming the supported macOS range. +#' @keywords internal +macos_support_range <- function() { + newest_major <- base::as.integer( + base::sub("\\..*", "", first_unsupported_macos_version()) + ) - 1L + + base::paste0( + "macOS High Sierra (10.13) through macOS ", + newest_supported_macos_name(), " (", newest_major, ".x)" + ) } #' Check if macOS Version is Supported for R @@ -79,7 +171,25 @@ macos_version_in_range <- function(lower, upper) { #' @return TRUE if macOS version is supported, FALSE otherwise #' @keywords internal is_macos_r_supported <- function() { - macos_version_in_range("10.13.0", "27.0") + macos_version_in_range( + minimum_supported_macos_version(), + first_unsupported_macos_version() + ) +} + +#' Check if macOS Golden Gate +#' +#' Golden Gate is macOS 27.x, released in September 2026. +#' +#' @details +#' macOS Golden Gate (version 27.x) is the successor to macOS Tahoe (version +#' 26.x). It is the first macOS release to run exclusively on Apple Silicon; +#' Tahoe was the last release to support Intel Macs. +#' +#' @return TRUE if system is macOS Golden Gate, FALSE otherwise +#' @keywords internal +is_macos_golden_gate <- function() { + macos_version_in_range("27.0", "28.0") } #' Check if macOS Tahoe @@ -87,7 +197,9 @@ is_macos_r_supported <- function() { #' Tahoe is macOS 26.x, released in late 2025. #' #' @details -#' macOS Tahoe (version 26.x) is the successor to macOS Sequoia (version 15.x). +#' macOS Tahoe (version 26.x) is the successor to macOS Sequoia (version 15.x) +#' and the predecessor of macOS Golden Gate (version 27.x). It is the last +#' macOS release to support Intel Macs. #' #' @return TRUE if system is macOS Tahoe, FALSE otherwise #' @keywords internal diff --git a/R/zzz.R b/R/zzz.R index 1af4821..e917733 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -17,8 +17,8 @@ if (!is_macos_r_supported()) { mac_version <- shell_mac_version() supported_msg <- base::paste0( - "Supported macOS versions: ", - "High Sierra (10.13) through Tahoe (26.x)" + "Supported versions: ", + macos_support_range() ) base::packageStartupMessage(cli::format_inline( diff --git a/man/is_macos_golden_gate.Rd b/man/is_macos_golden_gate.Rd new file mode 100644 index 0000000..f3a4d7e --- /dev/null +++ b/man/is_macos_golden_gate.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{is_macos_golden_gate} +\alias{is_macos_golden_gate} +\title{Check if macOS Golden Gate} +\usage{ +is_macos_golden_gate() +} +\value{ +TRUE if system is macOS Golden Gate, FALSE otherwise +} +\description{ +Golden Gate is macOS 27.x, released in September 2026. +} +\details{ +macOS Golden Gate (version 27.x) is the successor to macOS Tahoe (version +26.x). It is the first macOS release to run exclusively on Apple Silicon; +Tahoe was the last release to support Intel Macs. +} +\keyword{internal} diff --git a/man/is_macos_tahoe.Rd b/man/is_macos_tahoe.Rd index 3e0f36b..97982aa 100644 --- a/man/is_macos_tahoe.Rd +++ b/man/is_macos_tahoe.Rd @@ -13,6 +13,8 @@ TRUE if system is macOS Tahoe, FALSE otherwise Tahoe is macOS 26.x, released in late 2025. } \details{ -macOS Tahoe (version 26.x) is the successor to macOS Sequoia (version 15.x). +macOS Tahoe (version 26.x) is the successor to macOS Sequoia (version 15.x) +and the predecessor of macOS Golden Gate (version 27.x). It is the last +macOS release to support Intel Macs. } \keyword{internal} diff --git a/man/macos_support_range.Rd b/man/macos_support_range.Rd new file mode 100644 index 0000000..39ce501 --- /dev/null +++ b/man/macos_support_range.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{macos_support_range} +\alias{macos_support_range} +\title{Human-readable macOS Support Window} +\usage{ +macos_support_range() +} +\value{ +A string naming the supported macOS range. +} +\description{ +Renders the supported macOS range for user-facing messages. +} +\details{ +The upper major version is derived from \code{\link[=first_unsupported_macos_version]{first_unsupported_macos_version()}} +rather than written out, so the advertised range cannot disagree with the +check it describes. +} +\keyword{internal} diff --git a/man/macos_version_in_range.Rd b/man/macos_version_in_range.Rd index c6bbcaf..c9276d8 100644 --- a/man/macos_version_in_range.Rd +++ b/man/macos_version_in_range.Rd @@ -17,4 +17,9 @@ TRUE if the running macOS version is in \verb{[lower, upper)}. \description{ Check if the macOS Version Falls in a Range } +\details{ +The running version and both bounds are padded by \code{\link[=pad_version]{pad_version()}} before +comparison, so a bound may be written with two or three components +interchangeably. +} \keyword{internal} diff --git a/man/newest_supported_macos_name.Rd b/man/newest_supported_macos_name.Rd new file mode 100644 index 0000000..459b03c --- /dev/null +++ b/man/newest_supported_macos_name.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{newest_supported_macos_name} +\alias{newest_supported_macos_name} +\title{Marketing Name of the Newest Supported macOS Release} +\usage{ +newest_supported_macos_name() +} +\value{ +The marketing name of the newest supported macOS release. +} +\description{ +Marketing Name of the Newest Supported macOS Release +} +\keyword{internal} diff --git a/man/pad_version.Rd b/man/pad_version.Rd new file mode 100644 index 0000000..0483318 --- /dev/null +++ b/man/pad_version.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{pad_version} +\alias{pad_version} +\title{Pad a Version String to major.minor.patch} +\usage{ +pad_version(version) +} +\arguments{ +\item{version}{A version string such as \code{"27"}, \code{"27.0"} or \code{"27.0.1"}.} +} +\value{ +The version padded to at least three components. +} +\description{ +Pad a Version String to major.minor.patch +} +\details{ +\code{sw_vers -productVersion} reports only two components for a \code{.0} release: +macOS 27.0 reports \code{"27.0"}, never \code{"27.0.0"}. \code{\link[utils:compareVersion]{utils::compareVersion()}} +ranks a shorter string \emph{below} an otherwise-equal longer one, so +\code{compareVersion("10.13", "10.13.0")} is \code{-1}. Comparing an unpadded +two-component version against three-component bounds therefore places every +\code{.0} release in the \emph{previous} release's range. + +Padding both the version and the bounds to three components removes that +ambiguity. Versions with more than three components are left untouched. +} +\keyword{internal} diff --git a/man/recipes_binary_install.Rd b/man/recipes_binary_install.Rd index 603c02d..c74e197 100644 --- a/man/recipes_binary_install.Rd +++ b/man/recipes_binary_install.Rd @@ -62,9 +62,16 @@ repository and the install path are either:\tabular{lll}{ The correct repository is detected automatically: the highest \code{darwin} -version less than or equal to your macOS is selected for your architecture. -R 4.6 on Apple Silicon (macOS 14+) therefore resolves to \code{darwin23/arm64}, -which installs to the same \verb{/opt/R/arm64} prefix as \code{darwin20/arm64}. +version less than or equal to your \emph{Darwin kernel} version (\code{uname -r}) is +selected for your architecture. R 4.6 on Apple Silicon (macOS 14+) therefore +resolves to \code{darwin23/arm64}, which installs to the same \verb{/opt/R/arm64} +prefix as \code{darwin20/arm64}. + +Note that the Darwin version is not the macOS version, and the two are no +longer a fixed offset apart. Darwin tracked \code{macOS + 9} through macOS 15 +(Darwin 24), stayed on 25 for macOS 26 (Tahoe), then realigned to match the +marketing number for macOS 27 (Darwin 27), skipping Darwin 26 entirely. +Always read the Darwin version from \code{uname -r} rather than deriving it. } \section{Differences}{ diff --git a/man/supported_macos_version.Rd b/man/supported_macos_version.Rd new file mode 100644 index 0000000..a4e2246 --- /dev/null +++ b/man/supported_macos_version.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{supported_macos_version} +\alias{supported_macos_version} +\alias{minimum_supported_macos_version} +\alias{first_unsupported_macos_version} +\title{Supported macOS Version Window} +\usage{ +minimum_supported_macos_version() + +first_unsupported_macos_version() +} +\value{ +A macOS version string. +} +\description{ +Single source of truth for the macOS versions whose toolchain \code{macrtools} +supports. +} +\details{ +\code{\link[=first_unsupported_macos_version]{first_unsupported_macos_version()}} is an \strong{exclusive} bound: it names the +first macOS version that is \emph{rejected}, not the newest one that is accepted. +Supporting a new macOS release therefore means setting it to the \emph{following} +major version, e.g. supporting macOS 27 requires \code{"28.0"}. + +Naming the newest supported release here instead silently excludes that +entire release. That mistake shipped twice: once for Tahoe +(\href{https://github.com/coatless-mac/macrtools/issues/28}{#28}) and again for +macOS 27. +} +\keyword{internal} diff --git a/tests/testthat/test-assertions.R b/tests/testthat/test-assertions.R index d824286..3622129 100644 --- a/tests/testthat/test-assertions.R +++ b/tests/testthat/test-assertions.R @@ -72,3 +72,20 @@ test_that("assert_r_version_supported throws error on unsupported R version", { ) expect_error(assert_r_version_supported(), regexp = "not supported") }) + +test_that("assert_macos_supported runs the real predicate against the version", { + # The existing unsupported-version test stubs is_macos_r_supported() to a + # constant, so it can never detect a wrong bound. Mock only the version + # string and let the real range check run. + local_mocked_bindings( + assert_mac = function(...) NULL, + shell_mac_version = function() "27.0" + ) + expect_no_error(assert_macos_supported()) + + local_mocked_bindings( + assert_mac = function(...) NULL, + shell_mac_version = function() "28.0" + ) + expect_error(assert_macos_supported(), regexp = "not supported") +}) diff --git a/tests/testthat/test-system.R b/tests/testthat/test-system.R index 299f53b..d38903f 100644 --- a/tests/testthat/test-system.R +++ b/tests/testthat/test-system.R @@ -106,3 +106,127 @@ test_that("supported R version window is the single source of truth", { expect_true(is_r_version_supported(version = minimum_supported_r_version())) expect_true(is_r_version_supported(version = maximum_supported_r_version())) }) + +test_that("macOS support window is the single source of truth", { + expect_equal(minimum_supported_macos_version(), "10.13.0") + # The ceiling is EXCLUSIVE: it names the first version we reject, not the + # newest we support. Writing the newest supported release here instead is + # the off-by-one that shipped for Tahoe (#28) and again for macOS 27. + expect_equal(first_unsupported_macos_version(), "28.0") +}) + +test_that("is_macos_r_supported accepts every release below the ceiling", { + for (v in c("10.13.0", "10.15.7", "11.7.10", "14.7.2", "15.6.1", + "26.0", "26.4.1", "27.0", "27.0.1", "27.9.9")) { + local_mocked_bindings(shell_mac_version = function() v) + expect_true(is_macos_r_supported(), info = v) + } +}) + +test_that("is_macos_r_supported rejects releases outside the window", { + for (v in c("10.12.0", "10.12.6", "28.0", "28.0.1", "29.0")) { + local_mocked_bindings(shell_mac_version = function() v) + expect_false(is_macos_r_supported(), info = v) + } +}) + +test_that("is_macos_golden_gate identifies macOS 27", { + for (v in c("27.0", "27.0.1", "27.3.2")) { + local_mocked_bindings(shell_mac_version = function() v) + expect_true(is_macos_golden_gate(), info = v) + } + for (v in c("26.4.1", "28.0")) { + local_mocked_bindings(shell_mac_version = function() v) + expect_false(is_macos_golden_gate(), info = v) + } +}) + +macos_predicates <- function() { + base::list( + high_sierra = is_macos_high_sierra, + mojave = is_macos_mojave, + catalina = is_macos_catalina, + big_sur = is_macos_big_sur, + monterey = is_macos_monterey, + ventura = is_macos_ventura, + sonoma = is_macos_sonoma, + sequoia = is_macos_sequoia, + tahoe = is_macos_tahoe, + golden_gate = is_macos_golden_gate + ) +} + +test_that("exactly one named macOS predicate matches any given release", { + # Guards against a new release both widening its own range and being + # swallowed by its predecessor's (e.g. bumping is_macos_tahoe() to 28.0). + predicates <- macos_predicates() + + samples <- c( + high_sierra = "10.13.6", mojave = "10.14.6", catalina = "10.15.7", + big_sur = "11.7.10", monterey = "12.7.6", ventura = "13.7.1", + sonoma = "14.7.2", sequoia = "15.6.1", tahoe = "26.4.1", + golden_gate = "27.0.1" + ) + + for (nm in base::names(samples)) { + local_mocked_bindings(shell_mac_version = function() samples[[nm]]) + matched <- base::names(base::Filter(function(f) f(), predicates)) + expect_equal(matched, nm, info = samples[[nm]]) + } +}) + +test_that("named macOS predicates handle the two-component form of a .0 release", { + # sw_vers -productVersion reports only two components for a .0 release: + # macOS 27.0 reports "27.0", never "27.0.0". compareVersion() ranks a + # shorter string BELOW an otherwise-equal longer one, so an unpadded + # two-component version compared against three-component bounds lands in + # the PREVIOUS release's range. + predicates <- macos_predicates() + + samples <- c( + high_sierra = "10.13", mojave = "10.14", catalina = "10.15", + big_sur = "11.0", monterey = "12.0", ventura = "13.0", + sonoma = "14.0", sequoia = "15.0", tahoe = "26.0", + golden_gate = "27.0" + ) + + for (nm in base::names(samples)) { + local_mocked_bindings(shell_mac_version = function() samples[[nm]]) + matched <- base::names(base::Filter(function(f) f(), predicates)) + expect_equal(matched, nm, info = samples[[nm]]) + } +}) + +test_that("is_macos_r_supported accepts the two-component form of its floor", { + # High Sierra GM reports "10.13". Rejecting it produces an error message + # that names 10.13 as supported in the same breath. + local_mocked_bindings(shell_mac_version = function() "10.13") + expect_true(is_macos_r_supported()) +}) + +test_that("version_between handles double-digit majors", { + # 10.x-only coverage would hide a lexical-vs-numeric comparison mistake. + expect_true(version_between("27.0", "10.13.0", "28.0")) + expect_true(version_between("9.0", "9.0", "28.0")) + expect_false(version_between("28.0", "10.13.0", "28.0")) + expect_false(version_between("28.0.1", "10.13.0", "28.0")) +}) + +test_that("pad_version pads to three components without truncating", { + expect_equal(pad_version("27"), "27.0.0") + expect_equal(pad_version("27.0"), "27.0.0") + expect_equal(pad_version("27.0.1"), "27.0.1") + expect_equal(pad_version("10.13"), "10.13.0") + # Longer versions are left alone rather than cut down. + expect_equal(pad_version("27.0.1.2"), "27.0.1.2") + # Padding must make the .0 form compare equal to its padded self. + expect_equal(utils::compareVersion(pad_version("27.0"), pad_version("27.0.0")), 0L) +}) +test_that("the advertised macOS window is derived from the enforced bound", { + # The range text must follow the bound rather than repeat it, which is how + # the advertised window silently went stale in past releases. + expect_match(macos_support_range(), "\\b27\\.x\\b") + + local_mocked_bindings(first_unsupported_macos_version = function() "29.0") + expect_match(macos_support_range(), "\\b28\\.x\\b") +}) From 78d00d5cc2baf57b026581b2e74eae6a89abbb07 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Wed, 16 Sep 2026 16:09:40 -0500 Subject: [PATCH 2/2] Bump GitHub Actions to latest versions --- .github/workflows/R-CMD-check.yaml | 2 +- .github/workflows/pkgdown.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 9de2e67..24da75a 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -21,7 +21,7 @@ jobs: R_KEEP_PKG_SOURCE: yes name: R ${{ matrix.R }} sample steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: r-lib/actions/setup-r@v2 with: diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 86d1727..1d2be78 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -22,7 +22,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - uses: r-lib/actions/setup-pandoc@v2 @@ -41,7 +41,7 @@ jobs: - name: Deploy to GitHub pages 🚀 if: github.event_name != 'pull_request' - uses: JamesIves/github-pages-deploy-action@v4.4.1 + uses: JamesIves/github-pages-deploy-action@v4.9.0 with: clean: false branch: gh-pages