diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml new file mode 100644 index 0000000..1f37246 --- /dev/null +++ b/.github/workflows/check-upstream.yml @@ -0,0 +1,38 @@ +name: Check OpenMP upstream + +on: + schedule: + - cron: "17 6 * * 1" # Mondays 06:17 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Run sync + id: sync + shell: bash + run: | + set -euo pipefail + ./sync-openmp.sh | tee sync-summary.txt + + - name: Run tests + run: bash tests/run-tests.sh + + - name: Open PR on change + uses: peter-evans/create-pull-request@v8 + with: + branch: chore/sync-openmp-upstream + title: "chore: sync OpenMP versions from upstream" + body-path: sync-summary.txt + commit-message: "chore: sync OpenMP versions from upstream" + delete-branch: true + add-paths: | + install-openmp.sh + README.md diff --git a/.github/workflows/test-openmp.yml b/.github/workflows/test-openmp.yml new file mode 100644 index 0000000..a409011 --- /dev/null +++ b/.github/workflows/test-openmp.yml @@ -0,0 +1,34 @@ +name: OpenMP correctness + +on: + push: + branches: [main] + paths: + - install-openmp.sh + - test-openmp.sh + - .github/workflows/test-openmp.yml + pull_request: + branches: [main] + paths: + - install-openmp.sh + - test-openmp.sh + - .github/workflows/test-openmp.yml + workflow_dispatch: + +jobs: + openmp-correctness: + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Install OpenMP runtime + run: ./install-openmp.sh --yes + + - uses: r-lib/actions/setup-r@v2 + + - name: Run OpenMP correctness test + run: ./test-openmp.sh diff --git a/README.md b/README.md index 18d115a..1b7fe2e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ chmod +x uninstall-openmp.sh | Xcode Version | Apple Clang | OpenMP Version | Download | |---------------|-------------|----------------|----------| -| 16.3+ | 1700.x | 19.1.0 | [openmp-19.1.0-darwin20-Release.tar.gz](https://mac.r-project.org/openmp/openmp-19.1.0-darwin20-Release.tar.gz) | + +| 16.3-26.3 | 1700.x | 19.1.5 | [openmp-19.1.5-darwin20-Release.tar.gz](https://mac.r-project.org/openmp/openmp-19.1.5-darwin20-Release.tar.gz) | | 16.0-16.2 | 1600.x | 17.0.6 | [openmp-17.0.6-darwin20-Release.tar.gz](https://mac.r-project.org/openmp/openmp-17.0.6-darwin20-Release.tar.gz) | | 15.x | 1500.x | 16.0.4 | [openmp-16.0.4-darwin20-Release.tar.gz](https://mac.r-project.org/openmp/openmp-16.0.4-darwin20-Release.tar.gz) | | 14.3.x | 1403.x | 15.0.7 | [openmp-15.0.7-darwin20-Release.tar.gz](https://mac.r-project.org/openmp/openmp-15.0.7-darwin20-Release.tar.gz) | @@ -49,6 +50,7 @@ chmod +x uninstall-openmp.sh | 11.4-11.7 | 1103.x | 9.0.1 | [openmp-9.0.1-darwin17-Release.tar.gz](https://mac.r-project.org/openmp/openmp-9.0.1-darwin17-Release.tar.gz) | | 11.0-11.3.1 | 1100.x | 8.0.1 | [openmp-8.0.1-darwin17-Release.tar.gz](https://mac.r-project.org/openmp/openmp-8.0.1-darwin17-Release.tar.gz) | | 10.2-10.3 | 1001.x | 7.1.0 | [openmp-7.1.0-darwin17-Release.tar.gz](https://mac.r-project.org/openmp/openmp-7.1.0-darwin17-Release.tar.gz) | + > [!NOTE] > diff --git a/install-openmp.sh b/install-openmp.sh index d215bcf..4cc1eb9 100755 --- a/install-openmp.sh +++ b/install-openmp.sh @@ -63,18 +63,20 @@ show_help() { echo " - sudo privileges for installation to /usr/local/" echo "" echo -e "${YELLOW}SUPPORTED VERSIONS:${NC}" - echo " Xcode 16.3+ → OpenMP 19.1.0" - echo " Xcode 16.0-16.2 → OpenMP 17.0.6" - echo " Xcode 15.x → OpenMP 16.0.4" - echo " Xcode 14.3.x → OpenMP 15.0.7" - echo " Xcode 14.0-14.2 → OpenMP 14.0.6" - echo " Xcode 13.3-13.4.1 → OpenMP 13.0.0" - echo " Xcode 13.0-13.2.1 → OpenMP 12.0.1" - echo " Xcode 12.5 → OpenMP 11.0.1" - echo " Xcode 12.0-12.4 → OpenMP 10.0.0" - echo " Xcode 11.4-11.7 → OpenMP 9.0.1" - echo " Xcode 11.0-11.3.1 → OpenMP 8.0.1" - echo " Xcode 10.2-10.3 → OpenMP 7.1.0" + # >>> BEGIN GENERATED HELP VERSIONS (managed by sync-openmp.sh; do not edit) >>> + echo " Xcode 16.3-26.3 → OpenMP 19.1.5" + echo " Xcode 16.0-16.2 → OpenMP 17.0.6" + echo " Xcode 15.x → OpenMP 16.0.4" + echo " Xcode 14.3.x → OpenMP 15.0.7" + echo " Xcode 14.0-14.2 → OpenMP 14.0.6" + echo " Xcode 13.3-13.4.1 → OpenMP 13.0.0" + echo " Xcode 13.0-13.2.1 → OpenMP 12.0.1" + echo " Xcode 12.5 → OpenMP 11.0.1" + echo " Xcode 12.0-12.4 → OpenMP 10.0.0" + echo " Xcode 11.4-11.7 → OpenMP 9.0.1" + echo " Xcode 11.0-11.3.1 → OpenMP 8.0.1" + echo " Xcode 10.2-10.3 → OpenMP 7.1.0" + # <<< END GENERATED HELP VERSIONS <<< echo "" echo "MORE INFO:" echo " https://mac.r-project.org/openmp/" @@ -140,10 +142,11 @@ EXPECTED_SHA1="" BASE_URL="https://mac.r-project.org/openmp" case $CLANG_VERSION in + # >>> BEGIN GENERATED VERSION CASES (managed by sync-openmp.sh; do not edit) >>> 1700) - OPENMP_VERSION="19.1.0" + OPENMP_VERSION="19.1.5" DARWIN_TARGET="darwin20" - EXPECTED_SHA1="42a22fa5852bafc23ab31241d064f9be9aab8a0d" + EXPECTED_SHA1="5b44175bcbaa334b0c57391482e068ea185c95a2" ;; 1600) OPENMP_VERSION="17.0.6" @@ -200,22 +203,10 @@ case $CLANG_VERSION in DARWIN_TARGET="darwin17" EXPECTED_SHA1="6891ff6f83f2ed83eeed42160de819b50cf643cd" ;; + # <<< END GENERATED VERSION CASES <<< *) echo -e "${RED}Error: Unsupported clang version $CLANG_VERSION${NC}" - echo "Supported versions and their corresponding OpenMP builds:" - echo " 1700 (Xcode 16.3+) → OpenMP 19.1.0" - echo " 1600 (Xcode 16.0-16.2) → OpenMP 17.0.6" - echo " 1500 (Xcode 15.x) → OpenMP 16.0.4" - echo " 1403 (Xcode 14.3.x) → OpenMP 15.0.7" - echo " 1400 (Xcode 14.0-14.2) → OpenMP 14.0.6" - echo " 1316 (Xcode 13.3-13.4.1) → OpenMP 13.0.0" - echo " 1300 (Xcode 13.0-13.2.1) → OpenMP 12.0.1" - echo " 1205 (Xcode 12.5) → OpenMP 11.0.1" - echo " 1200 (Xcode 12.0-12.4) → OpenMP 10.0.0" - echo " 1103 (Xcode 11.4-11.7) → OpenMP 9.0.1" - echo " 1100 (Xcode 11.0-11.3.1) → OpenMP 8.0.1" - echo " 1001 (Xcode 10.2-10.3) → OpenMP 7.1.0" - echo "" + echo "Run '$0 --help' to see the supported Xcode/clang/OpenMP versions." echo "Please check https://mac.r-project.org/openmp/ for updates." exit 1 ;; diff --git a/sync-openmp.sh b/sync-openmp.sh new file mode 100755 index 0000000..fffb3cf --- /dev/null +++ b/sync-openmp.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash + +# OpenMP Setup - Automatic OpenMP installer for macOS +# Copyright (C) 2025: James J Balamuta +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +set -euo pipefail + +BASE_URL="https://mac.r-project.org/openmp" + +# parse_page +# Emits one record per *primary* tier: clangversiondarwinsha1xcode +parse_page() { + local html_file="$1" + # Strip HTML comments (defensive: drops the commented git build), then put one per line. + perl -0pe 's///gs' "$html_file" \ + | tr '\n' ' ' | sed 's/&2; continue; fi + ver=$(printf '%s' "$rel" | sed -E 's/^openmp-([0-9.]+)-darwin.*/\1/') + darwin=$(printf '%s' "$rel" | grep -oE 'darwin[0-9]+') + sha=$(printf '%s' "$row" | grep -oiE '[0-9a-f]{40}' | head -1) + if [ -z "$sha" ]; then echo "WARN: clang ${clang:-?} has no SHA1; skipping" >&2; continue; fi + xcode=$(printf '%s' "$row" | grep -oE 'Xcode [^(<]+' | head -1 | sed 's/[[:space:]]*$//') + if [ "$ver" != "$llvm" ]; then echo "WARN: clang $clang Release version ($ver) != LLVM heading ($llvm)" >&2; fi + printf '%s\t%s\t%s\t%s\t%s\n' "$clang" "$ver" "$darwin" "$sha" "$xcode" + done | sort -t"$(printf '\t')" -k1,1nr +} + +# All renderers read records (clang ver darwin sha xcode) from stdin. +render_case() { + while IFS=$'\t' read -r clang ver darwin sha xcode; do + printf ' %s)\n OPENMP_VERSION="%s"\n DARWIN_TARGET="%s"\n EXPECTED_SHA1="%s"\n ;;\n' \ + "$clang" "$ver" "$darwin" "$sha" + done +} + +render_help() { + while IFS=$'\t' read -r clang ver darwin sha xcode; do + printf ' echo " %s → OpenMP %s"\n' "$xcode" "$ver" + done +} + +render_readme() { + while IFS=$'\t' read -r clang ver darwin sha xcode; do + local short="${xcode#Xcode }" + local file="openmp-${ver}-${darwin}-Release.tar.gz" + printf '| %s | %s.x | %s | [%s](%s/%s) |\n' "$short" "$clang" "$ver" "$file" "$BASE_URL" "$file" + done +} + +# current_pin -> "versionsha1" or empty +current_pin() { + local clang="$1" file="$2" + awk -v key=" ${clang})" ' + $0==key {inblock=1; next} + inblock && /OPENMP_VERSION=/ {v=$0; sub(/.*OPENMP_VERSION="/,"",v); sub(/".*/,"",v)} + inblock && /EXPECTED_SHA1=/ {s=$0; sub(/.*EXPECTED_SHA1="/,"",s); sub(/".*/,"",s)} + inblock && /;;/ {print v"\t"s; exit} + ' "$file" +} + +sha1_of() { + if command -v shasum >/dev/null 2>&1; then shasum -a 1 "$1" | cut -d' ' -f1 + else sha1sum "$1" | cut -d' ' -f1; fi +} + +# resolve_sha +resolve_sha() { + local ver="$1" darwin="$2" page_sha="$3" + if [ "${OPENMP_SYNC_TRUST_PAGE:-0}" = "1" ]; then printf '%s' "$page_sha"; return 0; fi + local url="$BASE_URL/openmp-${ver}-${darwin}-Release.tar.gz" tmp got + tmp=$(mktemp) + if ! curl -fsS -o "$tmp" "$url"; then echo "ERROR: download failed: $url" >&2; rm -f "$tmp"; return 1; fi + got=$(sha1_of "$tmp"); rm -f "$tmp" + if [ -n "$page_sha" ] && [ "$got" != "$page_sha" ]; then + echo "WARN: computed SHA1 ($got) != page SHA1 ($page_sha) for $ver" >&2 + fi + printf '%s' "$got" +} + +# replace_block +replace_block() { + local file="$1" begin="$2" end="$3" content="$4" tmp + local nb ne + nb=$(grep -cF -- "$begin" "$file") || true + ne=$(grep -cF -- "$end" "$file") || true + if [ "$nb" != 1 ] || [ "$ne" != 1 ]; then + echo "ERROR: replace_block: markers not found exactly once in $file (begin=$nb end=$ne)" >&2 + return 1 + fi + local mode + mode=$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file") + tmp=$(mktemp) + awk -v b="$begin" -v e="$end" -v cf="$content" ' + index($0,b){print; while((getline line < cf)>0) print line; close(cf); skip=1; next} + index($0,e){skip=0; print; next} + !skip{print} + ' "$file" > "$tmp" || { rm -f "$tmp"; return 1; } + chmod "$mode" "$tmp" && mv "$tmp" "$file" +} + +# Build resolved records (compute-on-change) and the per-tier summary. +# Writes resolved records to stdout; appends summary lines to the file named by $1. +resolve_records() { + local summary_file="$1" install="$2" page_records="$3" + while IFS=$'\t' read -r clang ver darwin sha_page xcode; do + local pin cur_ver cur_sha sha + pin=$(current_pin "$clang" "$install") + cur_ver=$(printf '%s' "$pin" | cut -f1); cur_sha=$(printf '%s' "$pin" | cut -f2) + if [ "$cur_ver" = "$ver" ] && [ -n "$cur_sha" ]; then + sha="$cur_sha" + else + sha=$(resolve_sha "$ver" "$darwin" "$sha_page") || return 1 + if [ -z "$cur_ver" ]; then echo "NEW $clang: $ver" >> "$summary_file" + else echo "UPDATED $clang: $cur_ver -> $ver" >> "$summary_file"; fi + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$clang" "$ver" "$darwin" "$sha" "$xcode" + done <<< "$page_records" +} + +main() { + local check=0 + [ "${1:-}" = "--check" ] && check=1 + + local script_dir install readme page records summary + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + install="$script_dir/install-openmp.sh" + readme="$script_dir/README.md" + + page=$(mktemp) + if [ -n "${OPENMP_SYNC_PAGE:-}" ]; then cp "$OPENMP_SYNC_PAGE" "$page" + else curl -fsS "$BASE_URL/" -o "$page" || { echo "ERROR: cannot fetch $BASE_URL/" >&2; exit 1; }; fi + + records=$(parse_page "$page") + rm -f "$page" + [ -n "$records" ] || { echo "ERROR: parsed zero records (page structure changed?)" >&2; exit 1; } + + summary=$(mktemp) + local resolved + resolved=$(resolve_records "$summary" "$install" "$records") || { echo "ERROR: SHA resolution failed" >&2; exit 1; } + + # Render blocks into temp files. + local f_case f_help f_readme + f_case=$(mktemp); f_help=$(mktemp); f_readme=$(mktemp) + printf '%s\n' "$resolved" | render_case > "$f_case" + printf '%s\n' "$resolved" | render_help > "$f_help" + printf '%s\n' "$resolved" | render_readme > "$f_readme" + + if [ "$check" = 1 ]; then + # Apply to copies and diff; write nothing to the real files. + local ci cr; ci=$(mktemp); cr=$(mktemp); cp "$install" "$ci"; cp "$readme" "$cr" + replace_block "$ci" "BEGIN GENERATED VERSION CASES" "END GENERATED VERSION CASES" "$f_case" + replace_block "$ci" "BEGIN GENERATED HELP VERSIONS" "END GENERATED HELP VERSIONS" "$f_help" + replace_block "$cr" "BEGIN GENERATED README TABLE" "END GENERATED README TABLE" "$f_readme" + local rc=0 + diff -q "$ci" "$install" >/dev/null || rc=1 + diff -q "$cr" "$readme" >/dev/null || rc=1 + rm -f "$f_case" "$f_help" "$f_readme" "$summary" "$ci" "$cr" + [ "$rc" = 0 ] && echo "up to date" || echo "DRIFT: run sync-openmp.sh" + exit "$rc" + fi + + replace_block "$install" "BEGIN GENERATED VERSION CASES" "END GENERATED VERSION CASES" "$f_case" + replace_block "$install" "BEGIN GENERATED HELP VERSIONS" "END GENERATED HELP VERSIONS" "$f_help" + replace_block "$readme" "BEGIN GENERATED README TABLE" "END GENERATED README TABLE" "$f_readme" + rm -f "$f_case" "$f_help" "$f_readme" + + echo "Sync complete." + if [ -s "$summary" ]; then cat "$summary"; else echo "No version changes."; fi + rm -f "$summary" +} + +if [ "${OPENMP_SYNC_LIB:-}" != "1" ]; then + main "$@" +fi diff --git a/test-openmp.sh b/test-openmp.sh new file mode 100755 index 0000000..e644104 --- /dev/null +++ b/test-openmp.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash + +# OpenMP Setup - Automatic OpenMP installer for macOS +# Copyright (C) 2025: James J Balamuta +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +show_help() { + echo -e "${GREEN}OpenMP correctness test${NC}" + echo "Usage: $0 [--c-only] [-h|--help]" + echo "" + echo " Compiles and runs OpenMP programs (C, and an R package) against the" + echo " installed mac.r-project.org runtime and verifies real multithreading" + echo " AND a deterministic parallel-reduction result." + echo "" + echo " --c-only Run only the C toolchain check (skip the R package check)" + echo " -h, --help Show this help" + echo "" + echo "Exit codes: 0 pass, 1 single-thread, 2 _OPENMP undefined," + echo " 3 wrong C result, 4 R unavailable/install failed, 5 wrong R result," + echo " 6 C compile failed." + exit 0 +} + +C_ONLY=false +for arg in "$@"; do + case $arg in + -h|--help) show_help ;; + --c-only) C_ONLY=true ;; + *) echo -e "${RED}Error: Unknown option '$arg'${NC}"; echo "Use --help"; exit 1 ;; + esac +done + +if [[ "$OSTYPE" != darwin* ]]; then + echo -e "${YELLOW}SKIP: test-openmp.sh validates the macOS OpenMP runtime; current OS is not macOS.${NC}" + exit 0 +fi + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +run_c_test() { + cat > "$TMP/omp_test.c" <<'EOF' +#include +#include +int main(void) { +#ifndef _OPENMP + fprintf(stderr, "FAIL: _OPENMP not defined\n"); + return 2; +#endif + int observed = 0; + #pragma omp parallel + { + #pragma omp critical + { int t = omp_get_num_threads(); if (t > observed) observed = t; } + } + const long N = 1000000L; + long long expected = (long long)N * (N + 1) / 2; /* 500000500000 */ + long long sum = 0; + #pragma omp parallel for reduction(+:sum) + for (long i = 1; i <= N; ++i) sum += i; + printf("observed_threads=%d reduction_sum=%lld expected=%lld\n", observed, sum, expected); + if (sum != expected) return 3; + if (observed <= 1) return 1; + return 0; +} +EOF + echo "Compiling C OpenMP test..." + if ! clang -Xclang -fopenmp -I/usr/local/include -L/usr/local/lib -lomp \ + "$TMP/omp_test.c" -o "$TMP/omp_test"; then + echo -e "${RED}FAIL: C OpenMP program did not compile.${NC}" + return 6 + fi + + local out rc + out=$("$TMP/omp_test") && rc=0 || rc=$? + echo " default run: $out" + case $rc in + 0) ;; + 1) echo -e "${RED}FAIL: OpenMP linked but ran single-threaded.${NC}"; return 1 ;; + 2) echo -e "${RED}FAIL: _OPENMP not defined (OpenMP not enabled).${NC}"; return 2 ;; + 3) echo -e "${RED}FAIL: parallel reduction produced the wrong result.${NC}"; return 3 ;; + *) echo -e "${RED}FAIL: C test exited with unexpected code $rc.${NC}"; return $rc ;; + esac + + local out4 obs4 + out4=$(OMP_NUM_THREADS=4 "$TMP/omp_test") || true + echo " OMP_NUM_THREADS=4 run: $out4" + obs4=$(printf '%s\n' "$out4" | sed -n 's/.*observed_threads=\([0-9]*\).*/\1/p') + if [ "$obs4" != "4" ]; then + echo -e "${RED}FAIL: runtime did not honor OMP_NUM_THREADS=4 (observed=$obs4).${NC}" + return 1 + fi + echo -e "${GREEN}C toolchain OpenMP check passed.${NC}" +} + +run_c_test + +if [ "$C_ONLY" = true ]; then + echo -e "${GREEN}All requested OpenMP correctness checks passed (C only).${NC}" + exit 0 +fi + +run_r_test() { + if ! command -v Rscript >/dev/null 2>&1; then + echo -e "${RED}FAIL: Rscript not found; R package OpenMP check cannot run.${NC}" + return 4 + fi + + local pkg="$TMP/ompcheck" lib="$TMP/lib" + mkdir -p "$pkg/R" "$pkg/src" "$lib" + + cat > "$pkg/DESCRIPTION" <<'EOF' +Package: ompcheck +Version: 0.0.1 +Title: OpenMP CI Correctness Probe +Description: Minimal package running a parallel reduction to verify OpenMP. +Authors@R: person("CI", "Probe", email = "ci@example.com", role = c("aut", "cre")) +License: AGPL (>= 3) +EOF + + printf 'useDynLib(ompcheck, .registration = TRUE)\nexport(omp_sum)\n' > "$pkg/NAMESPACE" + printf 'omp_sum <- function(n) .Call(C_omp_sum, as.numeric(n))\n' > "$pkg/R/omp_sum.R" + + # NOTE: MUST be included before the R headers. R's headers before + # omp.h break omp.h's `declare variant` parsing on recent Apple clang. + cat > "$pkg/src/omp_sum.c" <<'EOF' +#include +#include +#include +#include + +SEXP C_omp_sum(SEXP nSEXP) { + long N = (long) Rf_asReal(nSEXP); + int observed = 0; + #pragma omp parallel + { + #pragma omp critical + { int t = omp_get_num_threads(); if (t > observed) observed = t; } + } + double sum = 0.0; + #pragma omp parallel for reduction(+:sum) + for (long i = 1; i <= N; ++i) sum += (double) i; + SEXP out = PROTECT(Rf_allocVector(REALSXP, 2)); + REAL(out)[0] = sum; + REAL(out)[1] = (double) observed; + UNPROTECT(1); + return out; +} + +static const R_CallMethodDef CallEntries[] = { + {"C_omp_sum", (DL_FUNC) &C_omp_sum, 1}, + {NULL, NULL, 0} +}; +void R_init_ompcheck(DllInfo *dll) { + R_registerRoutines(dll, NULL, CallEntries, NULL, NULL); + R_useDynamicSymbols(dll, FALSE); +} +EOF + + echo "Building R package 'ompcheck' with OpenMP (documented flags)..." + if ! PKG_CPPFLAGS='-Xclang -fopenmp -I/usr/local/include' \ + PKG_LIBS='-L/usr/local/lib -lomp' \ + R CMD INSTALL -l "$lib" "$pkg" >/dev/null 2>"$TMP/rinstall.log"; then + echo -e "${RED}FAIL: R CMD INSTALL of the OpenMP package failed.${NC}" + sed 's/^/ /' "$TMP/rinstall.log" | tail -15 + return 4 + fi + + echo "Running R package OpenMP check..." + if ! Rscript -e '.libPaths(c("'"$lib"'", .libPaths())) +v <- ompcheck::omp_sum(1e6) +cat(sprintf(" R result: sum=%.0f observed_threads=%d\n", v[1], as.integer(v[2]))) +stopifnot(v[1] == 1e6 * (1e6 + 1) / 2, v[2] > 1) +cat(" R parallel reduction correct and multithreaded.\n")'; then + echo -e "${RED}FAIL: R OpenMP package produced a wrong or single-threaded result.${NC}" + return 5 + fi + echo -e "${GREEN}R package OpenMP check passed.${NC}" +} + +run_r_test +echo -e "${GREEN}All OpenMP correctness checks passed.${NC}" diff --git a/tests/fixtures/golden-records.tsv b/tests/fixtures/golden-records.tsv new file mode 100644 index 0000000..9b01a3d --- /dev/null +++ b/tests/fixtures/golden-records.tsv @@ -0,0 +1,12 @@ +1700 19.1.5 darwin20 5b44175bcbaa334b0c57391482e068ea185c95a2 Xcode 16.3-26.3 +1600 17.0.6 darwin20 a89cab4e763025f03a5d12a93a609ff771ad209c Xcode 16.0-16.2 +1500 16.0.4 darwin20 591136d3c1cc26f3a21f1202a652be911bf1a2ad Xcode 15.x +1403 15.0.7 darwin20 31f0be747101b2bdce3c01b4d1c9041959bb3b27 Xcode 14.3.x +1400 14.0.6 darwin20 19912991431ecf032f037b6e8aea19dbd490f1ba Xcode 14.0-14.2 +1316 13.0.0 darwin21 47af4cb0d1f3554969f2ec9dee450d728ea30024 Xcode 13.3-13.4.1 +1300 12.0.1 darwin20 4fab53ccc420ab882119256470af15c210d19e5e Xcode 13.0-13.2.1 +1205 11.0.1 darwin20 0dcd19042f01c4f552914e2cf7a53186de397aa1 Xcode 12.5 +1200 10.0.0 darwin17 9bf16a64ab747528c5de7005a1ea1a9e318b3cf0 Xcode 12.0-12.4 +1103 9.0.1 darwin17 e5bd8501a3f957b4babe27b0a266d4fa15dbc23f Xcode 11.4-11.7 +1100 8.0.1 darwin17 e4612bfcb1bf520bf22844f7db764cadb7577c28 Xcode 11.0-11.3.1 +1001 7.1.0 darwin17 6891ff6f83f2ed83eeed42160de819b50cf643cd Xcode 10.2-10.3 diff --git a/tests/fixtures/page.html b/tests/fixtures/page.html new file mode 100644 index 0000000..3fc01cc --- /dev/null +++ b/tests/fixtures/page.html @@ -0,0 +1,239 @@ + + + OpenMP on macOS with Xcode tools + + + +

OpenMP on macOS with Xcode tools

+

+

+ Warning! Everything described on this page is strictly + experimental and not officially supported by Apple. + It may break at any time. The information is + provided in the hope of being useful to some tech-savvy + people. It is not intended for the regular R user. +
+

+ For those impatient, skip to how to enable OpenMP in + packages. +

+

OpenMP support in Xcode

+

+ Apple has explicitly disabled OpenMP support in compilers that + they ship in Xcode: + +

   $ clang -c omp.c -fopenmp
+   clang: error: unsupported option '-fopenmp'
+ + even though clang had OpenMP support for quite a long + time now (great thanks to the folks at Intel providing their + library as open source!). In fact, the clang compiler in Xcode + can generate all the necessary code for OpenMP. It can be tricked + into performing its designed function by using + -Xclang -fopenmp flags. +

+

+ The unfortunate part about this is that Apple is not shipping the + necesssary libomp.dylib run-time library needed for + OpenMP support. Fortunately, some clever folks were able to + + match the versions so we can build the binaries + that correspond to the clang version used. It is + sometimes possible to use a more recent version of the runtime + than the version of Apple clang. A notable exception is Xcode 16.3 + (Apple clang 1700+) which is incompatible with previous versions. + +

OpenMP run-time downloads

+ The following are links to libomp OpenMP run-time built + from official LLVM release sources using Xcode compilers. They + are signed and support macOS 10.13 (High Sierra) and higher + (x86_64) or macOS 11 (Big Sur) or higher (arm64). All + tar-balls contain the system tree usr/local/lib + and usr/local/include so the recommended installation is to type in Terminal: +
+    curl -O https://mac.r-project.org/openmp/openmp-17.0.6-darwin20-Release.tar.gz
+    sudo tar fvxz openmp-17.0.6-darwin20-Release.tar.gz -C /
+

NOTE: Do NOT use a browser to download the tar balls, because it will quarantine the downloaded file and its contents. Modern macOS security doesn't allow the use of quarantined libraries so you'd have to remove the quarantine first with xattr -c if you do so.

+

The contained set of files is the same in all tar balls + (except 19.1.5 which adds ompx.h) +

+    usr/local/lib/libomp.dylib
+    usr/local/include/ompt.h
+    usr/local/include/omp.h
+    usr/local/include/omp-tools.h
+ so you can simply remove those to uninstall. Note that any package + you compile against libomp.dylib will need that run-time + so you have to ship it with your package or have users install it. + Note, however, that CRAN R ships with libomp.dylib + from here in $R_HOME/lib (corresponding to the Xcode + version used on CRAN) so if you link against that location (recommended) + you don't have to ship it if users are using CRAN binaries of R + as long as you make sure you use a compatible run-time. +

+ You can verify the signature in the library via codetool (see below). +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuildDownloadSHA1 checksum
LLVM 19.1.5
Xcode 16.3-26.3 (Apple clang 1700.x)
openmp-19.1.5-darwin20-Release.tar.gz (Release)
+ openmp-19.1.5-darwin20-Debug.tar.gz (Debug)
5b44175bcbaa334b0c57391482e068ea185c95a2
759bc6348431c793b22438f6bd714af59c071036
LLVM 18.1.8
 
openmp-18.1.8-darwin20-Release.tar.gz (Release)
+ openmp-18.1.8-darwin20-Debug.tar.gz (Debug)
b8e7b79d265310ba12672e117df48ccfd9ce0366
115629b4ca79af7155a0bbe76932e7a2c9f3c313
LLVM 17.0.6
Xcode 16.0-16.2 (Apple clang 1600.x)
openmp-17.0.6-darwin20-Release.tar.gz (Release)
+ openmp-17.0.6-darwin20-Debug.tar.gz (Debug)
a89cab4e763025f03a5d12a93a609ff771ad209c
d74afdd50cd1fb4d17bdb45c2467ffeea530b1a2
LLVM 16.0.4
Xcode 15.x (Apple clang 1500.x)
openmp-16.0.4-darwin20-Release.tar.gz (Release)
+ openmp-16.0.4-darwin20-Debug.tar.gz (Debug)
591136d3c1cc26f3a21f1202a652be911bf1a2ad
1253f7157f590804031095440ffd80aac016b101
+
LLVM 15.0.7
Xcode 14.3.x (Apple clang 1403.x)
openmp-15.0.7-darwin20-Release.tar.gz (Release)
+ openmp-15.0.7-darwin20-Debug.tar.gz (Debug)
31f0be747101b2bdce3c01b4d1c9041959bb3b27
17728c6592d341400bb6076add9c2524a95305b7
LLVM 14.0.6
Xcode 14.0-14.2 (Apple clang 1400.x)
openmp-14.0.6-darwin20-Release.tar.gz (Release)
+ openmp-14.0.6-darwin20-Debug.tar.gz (Debug)
19912991431ecf032f037b6e8aea19dbd490f1ba
6b96a15db9329ea6f605449a630575036fa20aae
LLVM 13.0.0
Xcode 13.3-13.4.1 (Apple clang 1316.x)
openmp-13.0.0-darwin21-Release.tar.gz (Release)
+ openmp-13.0.0-darwin21-Debug.tar.gz (Debug)
47af4cb0d1f3554969f2ec9dee450d728ea30024
f6f8f1f49c02d5ec0729b56ddc7eaf51e7f04714
LLVM 12.0.1
Xcode 13.0-13.2.1 (Apple clang 1300.x)
openmp-12.0.1-darwin20-Release.tar.gz (Release)
+ openmp-12.0.1-darwin20-Debug.tar.gz (Debug)
4fab53ccc420ab882119256470af15c210d19e5e
58b4323e7933e12cba5c2996b4c9ef27567c41d9
LLVM 11.0.1 (+M1 patch)
Xcode 12.5 (Apple clang 1205.x)
openmp-11.0.1-darwin20-Release.tar.gz (Release)
+ openmp-11.0.1-darwin20-Debug.tar.gz (Debug)
0dcd19042f01c4f552914e2cf7a53186de397aa1
65e83ea667c72bbe44fea699776564d2f03a080f
(All binaries above include both arm64 and x86_64 and require macOS 11 or higher. Binaries below require macOS 10.13 or higher and are Intel-only)
LLVM 10.0.0
Xcode 12.0-12.4 (Apple clang 1200.x)
openmp-10.0.0-darwin17-Release.tar.gz (Release)
+ openmp-10.0.0-darwin17-Debug.tar.gz (Debug)
9bf16a64ab747528c5de7005a1ea1a9e318b3cf0
d4508d3f0c2952c3f984393b088e0b4beab33b58
LLVM 9.0.1
Xcode 11.4-11.7 (Apple clang 1103.x)
openmp-9.0.1-darwin17-Release.tar.gz (Release)
+ openmp-9.0.1-darwin17-Debug.tar.gz (Debug)
e5bd8501a3f957b4babe27b0a266d4fa15dbc23f
c4c8491631504fb060f7c25ec14324d02d617d5b
LLVM 8.0.1
Xcode 11.0-11.3.1 (Apple clang 1100.x)
openmp-8.0.1-darwin17-Release.tar.gz (Release)
+ openmp-8.0.1-darwin17-Debug.tar.gz (Debug)
e4612bfcb1bf520bf22844f7db764cadb7577c28
d6c83918b28405d43950d4b864ca8d1687eed4d1
LLVM 7.1.0
Xcode 10.2-10.3 (Apple clang 1001.x)
openmp-7.1.0-darwin17-Release.tar.gz (Release)
+ openmp-7.1.0-darwin17-Debug.tar.gz (Debug)
6891ff6f83f2ed83eeed42160de819b50cf643cd
34456adde62b9a1047f906e1d7f54990a1c15a34
+ +

How to enable OpenMP in packages

+
  • Download the libomp run-time corresponding to the + Xcode version you use from the links above
  • +
  • add -Xclang -fopenmp to CPPFLAGS, + add -lomp to LIBS
  • +
+ How you do the latter depends on the package, but if the package does + not set these environment variables itself, you can try +
+    PKG_CPPFLAGS='-Xclang -fopenmp' PKG_LIBS=-lomp R CMD INSTALL myPackage
+ + If that doesn't work, please consult the package's documentation, and + liaise with its maintainer. + + It is also possible to add those flags globally by + adding the following to ~/.R/Makevars: +
+    CPPFLAGS += -Xclang -fopenmp
+    LDFLAGS += -lomp
+ + but be very careful when doing this, always check + your ~/.R/Makevars whenever you upgrade R, macOS or Xcode! + +

Note: If you are using CRAN R binary and there is a potential + conflict between its run-time and your run-time (which is generally + a bad idea!) then you can force the use of your binary by + replacing -lomp above with /usr/local/lib/libomp.dylib + (note that lack of -l!).

+ +

Side notes

+ It may be possible in principle to build static version of the run-time. + That can be done via -DLIBOMP_ENABLE_SHARED=OFF, but has not been + tested. There is a potential for chaos when more OMP run-times get + loaded into one process as they may clash, in fact it is strongly + discouraged to use static run-time, because only one version the + run-time may be loaded into a process. That is why R package are + encouraged to use the common run-time supplied with CRAN R + binaries instead of shipping their own (see above). +

+ Another interesting test would be to + try -DLIBOMP_FORTRAN_MODULES=ON and see which versions + are compatible with the GNU Fortran used in R. We are + intentionally removing the gomp symlink from the binary + so that iomp and gomp don't conflict. Future R + releases may use LLVM-based Fortran complier with OpenMP support. + +

License and sources

+ All sources were obtained directly from the + LLVM releases + and copies are also available in the src + directory (including the build script). See LICENSE.txt + in the sources for the corresponding license and + https://openmp.llvm.org/ + for details on the OpenMP run-time. + +

Verifying code signatures

+ You can use codetools to verify the signature, for example you should see: +
+$ codesign -d -vv /usr/local/lib/libomp.dylib 
+Executable=/usr/local/lib/libomp.dylib
+Identifier=libomp
+Format=Mach-O universal (x86_64 arm64)
+CodeDirectory v=20400 size=5514 flags=0x0(none) hashes=167+2 location=embedded
+Signature size=8927
+Authority=Developer ID Application: Simon Urbanek (VZLD955F6P)
+Authority=Developer ID Certification Authority
+Authority=Apple Root CA
+Timestamp=11/11/2021 at 1:29:04 PM
+Info.plist=not bound
+TeamIdentifier=VZLD955F6P
+Sealed Resources=none
+Internal requirements count=1 size=168
+
+ +

Acknowledgements

+ Thanks to John Clayden, Kevin Ushey and others who contributed to the discussion and testing. + +

Trademark notices

+
    +
  • The OpenMP name and the OpenMP logo are registered trademarks of + the OpenMP Architecture Review Board.
  • +
  • Intel is a trademark of Intel Corporation in the U.S. and/or other countries.
  • +
  • Apple, Xcode and macOS are trademarks of Apple Inc., registered in the U.S. and other countries.
  • +
+

+ Last modified on 2025/12/11 by Simon Urbanek + + diff --git a/tests/helpers.sh b/tests/helpers.sh new file mode 100644 index 0000000..89c35bc --- /dev/null +++ b/tests/helpers.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +fail() { echo "FAIL: $1" >&2; exit 1; } +assert_eq() { + # assert_eq actual expected message + if [ "$1" != "$2" ]; then + printf 'FAIL: %s\n expected: %s\n actual: %s\n' "$3" "$2" "$1" >&2 + exit 1 + fi +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..1d78a86 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +rc=0 +for t in "$DIR"/test-*.sh; do + echo "== $(basename "$t") ==" + bash "$t" || rc=1 +done +exit $rc diff --git a/tests/test-e2e.sh b/tests/test-e2e.sh new file mode 100644 index 0000000..4f6c0b0 --- /dev/null +++ b/tests/test-e2e.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")/.." && pwd)" +. "$DIR/tests/helpers.sh" + +work=$(mktemp -d) +cp "$DIR/install-openmp.sh" "$DIR/README.md" "$work/" +cp "$DIR/sync-openmp.sh" "$work/" +cp -r "$DIR/tests" "$work/tests" + +# Force a known OLD pre-state in the temp copy so the test is deterministic +# regardless of what the repo's install-openmp.sh currently pins for clang 1700. +# We rewrite only the 1700 case block in the temp copy (never the repo file). +awk ' + /^ 1700\)/ { in1700=1 } + in1700 && /OPENMP_VERSION=/ && !ver_done { + sub(/OPENMP_VERSION="[^"]*"/, "OPENMP_VERSION=\"19.1.0\""); ver_done=1 + } + in1700 && /EXPECTED_SHA1=/ && !sha_done { + sub(/EXPECTED_SHA1="[^"]*"/, "EXPECTED_SHA1=\"42a22fa5852bafc23ab31241d064f9be9aab8a0d\""); sha_done=1 + } + in1700 && /^ ;;/ { in1700=0 } + { print } +' "$work/install-openmp.sh" > "$work/install-openmp.sh.tmp" \ + && mv "$work/install-openmp.sh.tmp" "$work/install-openmp.sh" + +# Run sync against the fixture, trusting page SHA (offline), writing into the temp copies. +( cd "$work" && OPENMP_SYNC_PAGE="tests/fixtures/page.html" OPENMP_SYNC_TRUST_PAGE=1 \ + bash sync-openmp.sh > summary.txt ) + +grep -q '19.1.5' "$work/install-openmp.sh" || fail "install case not updated to 19.1.5" +grep -q '5b44175bcbaa334b0c57391482e068ea185c95a2' "$work/install-openmp.sh" || fail "install SHA not updated" +grep -q '| 16.3-26.3 | 1700.x | 19.1.5 |' "$work/README.md" || fail "README row not updated" +grep -q 'UPDATED 1700' "$work/summary.txt" || fail "summary missing UPDATED 1700" + +# Running --check on the freshly-synced tree reports no drift (exit 0). +( cd "$work" && OPENMP_SYNC_PAGE="tests/fixtures/page.html" OPENMP_SYNC_TRUST_PAGE=1 \ + bash sync-openmp.sh --check ) || fail "--check reported drift after sync" +rm -rf "$work" +echo "PASS: test-e2e" diff --git a/tests/test-edit.sh b/tests/test-edit.sh new file mode 100644 index 0000000..79872cd --- /dev/null +++ b/tests/test-edit.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")/.." && pwd)" +. "$DIR/tests/helpers.sh" +OPENMP_SYNC_LIB=1 . "$DIR/sync-openmp.sh" + +# current_pin reads the live install-openmp.sh +pin=$(current_pin 1600 "$DIR/install-openmp.sh") +assert_eq "$pin" $'17.0.6\ta89cab4e763025f03a5d12a93a609ff771ad209c' "current_pin 1600" +assert_eq "$(current_pin 9999 "$DIR/install-openmp.sh")" "" "current_pin unknown -> empty" + +# resolve_sha trust-page path is offline + deterministic +assert_eq "$(OPENMP_SYNC_TRUST_PAGE=1 resolve_sha 19.1.5 darwin20 deadbeef)" "deadbeef" "resolve_sha trust-page" + +# replace_block swaps content between markers, atomically +tmp=$(mktemp); content=$(mktemp) +printf 'head\n# >>> BEGIN GENERATED X (managed by sync-openmp.sh; do not edit) >>>\nOLD\n# <<< END GENERATED X <<<\ntail\n' > "$tmp" +printf 'NEW1\nNEW2\n' > "$content" +replace_block "$tmp" "BEGIN GENERATED X" "END GENERATED X" "$content" +got=$(cat "$tmp"); rm -f "$tmp" "$content" +want=$'head\n# >>> BEGIN GENERATED X (managed by sync-openmp.sh; do not edit) >>>\nNEW1\nNEW2\n# <<< END GENERATED X <<<\ntail' +assert_eq "$got" "$want" "replace_block swaps between markers" +# replace_block must refuse (non-zero, file unchanged) when END marker is absent +tmp2=$(mktemp); content2=$(mktemp) +printf 'head\n# >>> BEGIN GENERATED X (managed by sync-openmp.sh; do not edit) >>>\nOLD\ntail\n' > "$tmp2" +printf 'NEW\n' > "$content2" +orig2=$(cat "$tmp2") +if OPENMP_SYNC_LIB=1 replace_block "$tmp2" "BEGIN GENERATED X" "END GENERATED X" "$content2" 2>/dev/null; then + fail "replace_block should return non-zero when END marker is missing" +fi +got2=$(cat "$tmp2") +rm -f "$tmp2" "$content2" +assert_eq "$got2" "$orig2" "replace_block leaves file unchanged when END marker is missing" + +echo "PASS: test-edit" diff --git a/tests/test-markers.sh b/tests/test-markers.sh new file mode 100644 index 0000000..788e0ec --- /dev/null +++ b/tests/test-markers.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")/.." && pwd)" +fail() { echo "FAIL: $1" >&2; exit 1; } + +# Every BEGIN marker has a matching END marker in each file. +for f in install-openmp.sh README.md; do + b=$(grep -c 'BEGIN GENERATED' "$DIR/$f" || true) + e=$(grep -c 'END GENERATED' "$DIR/$f" || true) + [ "$b" -ge 1 ] || fail "$f: no BEGIN markers" + [ "$b" = "$e" ] || fail "$f: $b BEGIN vs $e END markers" +done + +# install-openmp.sh must still be valid bash. +bash -n "$DIR/install-openmp.sh" || fail "install-openmp.sh has a syntax error" + +# Required generated-block names are present. +grep -q 'BEGIN GENERATED VERSION CASES' "$DIR/install-openmp.sh" || fail "missing VERSION CASES block" +grep -q 'BEGIN GENERATED HELP VERSIONS' "$DIR/install-openmp.sh" || fail "missing HELP VERSIONS block" +grep -q 'BEGIN GENERATED README TABLE' "$DIR/README.md" || fail "missing README TABLE block" +echo "PASS: test-markers" diff --git a/tests/test-openmp-syntax.sh b/tests/test-openmp-syntax.sh new file mode 100755 index 0000000..d46c67d --- /dev/null +++ b/tests/test-openmp-syntax.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")/.." && pwd)" +fail() { echo "FAIL: $1" >&2; exit 1; } + +[ -f "$DIR/test-openmp.sh" ] || fail "test-openmp.sh missing" +bash -n "$DIR/test-openmp.sh" || fail "test-openmp.sh has a syntax error" +grep -q 'omp_get_num_threads' "$DIR/test-openmp.sh" || fail "test-openmp.sh missing the OpenMP probe" +echo "PASS: test-openmp-syntax" diff --git a/tests/test-parse.sh b/tests/test-parse.sh new file mode 100644 index 0000000..588028e --- /dev/null +++ b/tests/test-parse.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")/.." && pwd)" +. "$DIR/tests/helpers.sh" +OPENMP_SYNC_LIB=1 . "$DIR/sync-openmp.sh" + +got=$(parse_page "$DIR/tests/fixtures/page.html") +want=$(cat "$DIR/tests/fixtures/golden-records.tsv") +assert_eq "$got" "$want" "parse_page output matches golden records" +echo "PASS: test-parse" diff --git a/tests/test-render.sh b/tests/test-render.sh new file mode 100644 index 0000000..f1501b8 --- /dev/null +++ b/tests/test-render.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")/.." && pwd)" +. "$DIR/tests/helpers.sh" +OPENMP_SYNC_LIB=1 . "$DIR/sync-openmp.sh" + +rec=$'1700\t19.1.5\tdarwin20\t5b44175bcbaa334b0c57391482e068ea185c95a2\tXcode 16.3-26.3' + +case_out=$(printf '%s\n' "$rec" | render_case) +want_case=$' 1700)\n OPENMP_VERSION="19.1.5"\n DARWIN_TARGET="darwin20"\n EXPECTED_SHA1="5b44175bcbaa334b0c57391482e068ea185c95a2"\n ;;' +assert_eq "$case_out" "$want_case" "render_case single tier" + +help_out=$(printf '%s\n' "$rec" | render_help) +assert_eq "$help_out" ' echo " Xcode 16.3-26.3 → OpenMP 19.1.5"' "render_help single tier" + +readme_out=$(printf '%s\n' "$rec" | render_readme) +want_readme='| 16.3-26.3 | 1700.x | 19.1.5 | [openmp-19.1.5-darwin20-Release.tar.gz](https://mac.r-project.org/openmp/openmp-19.1.5-darwin20-Release.tar.gz) |' +assert_eq "$readme_out" "$want_readme" "render_readme single tier" +echo "PASS: test-render"