Skip to content

Commit 5276d4f

Browse files
authored
Add a native build mode so macOS gets tested at all (#10)
Every build these definitions have been through was Linux in Docker, because test/build can only test via Docker. macOS is the platform this repo exists for and it has never been built with the current flags — so `test/build native`, which builds on the host into a throwaway prefix and runs the same post-flight checks the containers run. That matters most for the arithmetic assertion. The 2**64 == 0 bug was GCC exploiting signed-overflow UB at -O3; whether Apple clang does the same was untested, and untestable. Today's Macs all compute 2**64 correctly because none of them is at -O3 via CFLAGS — the risk arrives with the next rebuild, not before it, which is exactly when you want to have already checked. Opt-in by name. PLATFORMS is untouched, so `test/build all` stays the clean-room Docker matrix; a native build compiles against whatever the host happens to have and proves nothing about a fresh machine. The verify script now takes its prefix from $RUBY_PREFIX rather than hardcoding /opt/ruby, so both paths run the same assertions — the point of the mode is that macOS gets these and not a weaker set. test_ruby splits into docker_build and native_build with the result reporting shared; the docker invocation itself is unchanged. Two details worth their comments. ruby-build is resolved once up front and a missing one is a hard error, never a skip — a native run that can't build is a failed run. It usually isn't on PATH, since mise invokes its own copy directly, so mise's cache is consulted too, via `mise cache` rather than a hardcoded ~/.cache/mise: on macOS that cache lives under ~/Library/Caches, and macOS is the whole point here. Each build also gets its own TMPDIR, because ruby-build derives both its log path and its build directory from it and these run concurrently — otherwise a failure tail is some neighbour's log. Prefixes are removed as soon as they're verified rather than kept. Six Rubies is several GB and the machines that need this are laptops. Verified. macOS (M1 Max, Apple clang 21 under Xcode-beta): 6/6, arithmetic assertions included, so clang does not reproduce the GCC signed-overflow miscompile. Not a vacuous pass either — a 1.8.7 built there reports CFLAGS "-O3 -fno-strict-overflow …", CC clang, and 2**64 correct, so the flags really did reach the compile line. Arch native 6/6, and the Docker matrix still 12/12. One wrinkle worth knowing: 1.8.7's freshen_automake_config shells out to `brew install automake` on macOS, so that build inherits whatever state brew is in. It failed once mid-upgrade with a brew rename error, unrelated to anything here, and passed on a retry.
1 parent dfcd595 commit 5276d4f

1 file changed

Lines changed: 129 additions & 22 deletions

File tree

test/build

Lines changed: 129 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ set -euo pipefail
33

44
cd "$(dirname "$0")/.."
55

6+
# Docker platforms only. `native` is a platform too, but it is opt-in by name and
7+
# deliberately not in here: `test/build all` must stay a clean-room test, and a native
8+
# build compiles against whatever the host happens to have installed.
69
PLATFORMS="ubuntu-noble arch"
710

811
# macOS ships BSD sort, which rejects -V. Fall back to plain sort there rather than dying
@@ -41,14 +44,20 @@ usage() {
4144
cat <<EOF
4245
Usage: test/build <platform> [version]
4346
44-
Platforms: $(echo $PLATFORMS | tr ' ' ', '), all
47+
Platforms: $(echo $PLATFORMS | tr ' ' ', '), all, native
4548
Versions: $(echo $VERSIONS | tr '\n' ' '), all
4649
4750
Examples:
4851
test/build ubuntu-noble 2.7.8 # Test Ruby 2.7.8 on Ubuntu Noble
4952
test/build arch all # Test all versions on Arch
5053
test/build all 2.7.8 # Test Ruby 2.7.8 on all platforms
5154
test/build all # Test everything
55+
test/build native all # Test every version on this host, no Docker
56+
57+
The 'native' platform builds on the host with your own toolchain, into a
58+
throwaway prefix that is removed once verified. It is how macOS gets tested at
59+
all — there is no container for it — and it is never part of 'all', because it
60+
proves nothing about a clean machine.
5261
5362
Environment:
5463
JOBS=$JOBS Containers built concurrently
@@ -72,6 +81,10 @@ platform_for() {
7281

7382
build_image() {
7483
local platform=$1
84+
85+
# Nothing to build for a host build.
86+
[[ $platform == native ]] && return 0
87+
7588
local dockerfile="test/${platform}.dockerfile"
7689
local image="ruby-build-test:${platform}"
7790
local target_platform
@@ -107,6 +120,10 @@ build_image() {
107120

108121
# Default post-flight checks - can be overridden via test/verify/<version>
109122
#
123+
# $RUBY_PREFIX is supplied by whoever runs the script: /opt/ruby in a container, a
124+
# throwaway directory for a native build. Same assertions either way — the whole point of
125+
# the native mode is that macOS gets tested against these and not a weaker set.
126+
#
110127
# The arithmetic check is not paranoia. These sources predate the compilers building them
111128
# and their fixnum overflow checks assume signed overflow wraps, which is undefined
112129
# behaviour GCC exploits from -O2 up. A 1.8.7 built -O3 without -fno-strict-overflow
@@ -115,31 +132,25 @@ build_image() {
115132
# arithmetic. Kept 1.8.7-compatible: no interpolation-free heredocs, no modern syntax.
116133
default_verify_script() {
117134
cat <<'VERIFY'
118-
/opt/ruby/bin/ruby -e 'require "openssl"; puts "openssl: #{OpenSSL::OPENSSL_VERSION}"'
119-
/opt/ruby/bin/ruby -e 'require "digest/sha2"; puts "digest: ok"'
120-
/opt/ruby/bin/ruby -e 'require "zlib"; puts "zlib: ok"'
121-
/opt/ruby/bin/ruby -e 'raise "2**64 wrong: #{2**64}" unless (2**64).to_s == "18446744073709551616"; raise "2**100 wrong" unless (2**100).to_s == "1267650600228229401496703205376"; raise "mul overflow wrong" unless (4611686018427387903 * 2).to_s == "9223372036854775806"; raise "negative overflow wrong" unless (-2**64).to_s == "-18446744073709551616"; raise "10**20 wrong" unless (10**20).to_s == "100000000000000000000"; puts "arithmetic: ok"'
135+
"$RUBY_PREFIX"/bin/ruby -e 'require "openssl"; puts "openssl: #{OpenSSL::OPENSSL_VERSION}"'
136+
"$RUBY_PREFIX"/bin/ruby -e 'require "digest/sha2"; puts "digest: ok"'
137+
"$RUBY_PREFIX"/bin/ruby -e 'require "zlib"; puts "zlib: ok"'
138+
"$RUBY_PREFIX"/bin/ruby -e 'raise "2**64 wrong: #{2**64}" unless (2**64).to_s == "18446744073709551616"; raise "2**100 wrong" unless (2**100).to_s == "1267650600228229401496703205376"; raise "mul overflow wrong" unless (4611686018427387903 * 2).to_s == "9223372036854775806"; raise "negative overflow wrong" unless (-2**64).to_s == "-18446744073709551616"; raise "10**20 wrong" unless (10**20).to_s == "100000000000000000000"; puts "arithmetic: ok"'
122139
VERIFY
123140
}
124141

125-
test_ruby() {
142+
# Build in a container and run the post-flight checks there.
143+
docker_build() {
126144
local platform=$1
127145
local version=$2
146+
local verify_script=$3
128147
local image="ruby-build-test:${platform}"
129148
local target_platform
130149
target_platform=$(platform_for "$platform")
131150
local platform_flag=""
132151

133152
[[ -n "$target_platform" ]] && platform_flag="--platform $target_platform"
134153

135-
# Use version-specific verify script if it exists, otherwise use defaults
136-
local verify_script
137-
if [[ -f "test/verify/$version" ]]; then
138-
verify_script=$(cat "test/verify/$version")
139-
else
140-
verify_script=$(default_verify_script)
141-
fi
142-
143154
# Build Ruby and run post-flight checks.
144155
#
145156
# On failure, dump ruby-build's own log before exiting. Several definitions send the
@@ -148,30 +159,88 @@ test_ruby() {
148159
# curl progress bars.
149160
local build_script="
150161
set -e
151-
ruby-build $version /opt/ruby || {
162+
export RUBY_PREFIX=/opt/ruby
163+
ruby-build $version \"\$RUBY_PREFIX\" || {
152164
echo '--- ruby-build log (tail) ---'
153165
tail -60 /tmp/ruby-build.*.log 2>/dev/null
154166
exit 1
155167
}
156-
/opt/ruby/bin/ruby --version
168+
\"\$RUBY_PREFIX\"/bin/ruby --version
157169
$verify_script
158170
"
159171

160-
local started=$SECONDS output elapsed
161172
# $platform_flag is deliberately unquoted: it holds two words ("--platform
162173
# linux/amd64") and must split into two arguments. An array would be the tidier
163174
# idiom, but expanding an empty one under `set -u` is an error on macOS's Bash
164175
# 3.2, and this script has to keep working there.
165176
# shellcheck disable=SC2086
166-
if output=$(docker run --rm $platform_flag -e MAKE_OPTS="-j${MAKE_JOBS}" \
167-
"$image" bash -c "$build_script" 2>&1); then
168-
elapsed=$(( SECONDS - started ))
177+
docker run --rm $platform_flag -e MAKE_OPTS="-j${MAKE_JOBS}" \
178+
"$image" bash -c "$build_script"
179+
}
180+
181+
# Build on this host, with this host's toolchain, into a throwaway prefix — then run the
182+
# same checks the containers run. There is no macOS container, so this is the only way to
183+
# find out what Apple clang does with these sources, which is exactly the question the
184+
# arithmetic assertion answers.
185+
#
186+
# Both the prefix and ruby-build's scratch/log directory are removed as soon as the build
187+
# has been judged. A full matrix is six Rubies and several GB, and the machines that need
188+
# this test are laptops.
189+
native_build() {
190+
local version=$1
191+
local verify_script=$2
192+
local prefix tmp rc=0
193+
194+
prefix=$(mktemp -d)
195+
# ruby-build derives both its log path and its build directory from TMPDIR. Giving each
196+
# build its own means the failure tail below is this build's log and not a neighbour's,
197+
# which matters because these run concurrently.
198+
tmp=$(mktemp -d)
199+
200+
if TMPDIR="$tmp" MAKE_OPTS="-j${MAKE_JOBS}" RUBY_BUILD_DEFINITIONS="$PWD" \
201+
"$RUBY_BUILD" "$version" "$prefix"; then
202+
local check_script="
203+
set -e
204+
\"\$RUBY_PREFIX\"/bin/ruby --version
205+
$verify_script
206+
"
207+
RUBY_PREFIX="$prefix" bash -c "$check_script" || rc=1
208+
else
209+
rc=1
210+
echo '--- ruby-build log (tail) ---'
211+
tail -60 "$tmp"/ruby-build.*.log 2>/dev/null || true
212+
fi
213+
214+
rm -rf "$prefix" "$tmp"
215+
return $rc
216+
}
217+
218+
test_ruby() {
219+
local platform=$1
220+
local version=$2
221+
222+
# Use version-specific verify script if it exists, otherwise use defaults
223+
local verify_script
224+
if [[ -f "test/verify/$version" ]]; then
225+
verify_script=$(cat "test/verify/$version")
226+
else
227+
verify_script=$(default_verify_script)
228+
fi
229+
230+
local started=$SECONDS output elapsed ruby_version status=pass
231+
if [[ $platform == native ]]; then
232+
output=$(native_build "$version" "$verify_script" 2>&1) || status=fail
233+
else
234+
output=$(docker_build "$platform" "$version" "$verify_script" 2>&1) || status=fail
235+
fi
236+
elapsed=$(( SECONDS - started ))
237+
238+
if [[ $status == pass ]]; then
169239
ruby_version=$(echo "$output" | grep -o 'ruby [0-9].*\]' | tail -1)
170240
# One printf so concurrent jobs can't interleave mid-line.
171241
printf ' %-14s %-14s ✓ %-62s %4ds\n' "$platform" "$version" "$ruby_version" "$elapsed"
172242
echo "pass" > "$RESULTS/$platform.$version.status"
173243
else
174-
elapsed=$(( SECONDS - started ))
175244
printf ' %-14s %-14s ✗ %-62s %4ds\n' "$platform" "$version" "FAILED" "$elapsed"
176245
echo "fail" > "$RESULTS/$platform.$version.status"
177246
# Keep the log for the end-of-run report rather than interleaving it with
@@ -195,9 +264,47 @@ version=${2:-all}
195264

196265
# Validate platform
197266
for p in $platforms; do
198-
[[ ! -f "test/${p}.dockerfile" ]] && { echo "Unknown platform: $p"; exit 1; }
267+
case $p in
268+
native) ;;
269+
*) [[ ! -f "test/${p}.dockerfile" ]] && { echo "Unknown platform: $p"; exit 1; } ;;
270+
esac
199271
done
200272

273+
# Resolve ruby-build once, up front, so a missing one is a single clear error before any
274+
# builds start rather than N identical failures inside background jobs.
275+
#
276+
# It often isn't on PATH: mise keeps its own copy and invokes it directly, so a machine that
277+
# builds these definitions every day can still have no `ruby-build` command. Ask mise where
278+
# that copy is rather than assuming ~/.cache/mise — on macOS, the platform this whole mode
279+
# exists for, the cache is under ~/Library/Caches. mise also only clones it when it has to
280+
# compile something, so a machine can have mise, have built these Rubies, and still not have
281+
# it cached; that's what the error below is for. Never a silent skip: a native run that
282+
# can't build is a failed run.
283+
mise_ruby_build() {
284+
local cache
285+
command -v mise >/dev/null 2>&1 || return 1
286+
cache=$(mise cache 2>/dev/null) || return 1
287+
[[ -n "$cache" && -x "$cache/ruby/ruby-build/bin/ruby-build" ]] || return 1
288+
printf '%s\n' "$cache/ruby/ruby-build/bin/ruby-build"
289+
}
290+
291+
RUBY_BUILD=""
292+
case " $platforms " in
293+
*" native "*)
294+
if command -v ruby-build >/dev/null 2>&1; then
295+
RUBY_BUILD=$(command -v ruby-build)
296+
elif RUBY_BUILD=$(mise_ruby_build); then
297+
:
298+
else
299+
echo "error: native builds need ruby-build, and none was found." >&2
300+
echo " Looked on PATH and in mise's cache." >&2
301+
echo " Install it — 'brew install ruby-build' on macOS — or use a Docker platform." >&2
302+
exit 1
303+
fi
304+
echo "Using ruby-build: $RUBY_BUILD"
305+
;;
306+
esac
307+
201308
# Validate version
202309
for v in $versions; do
203310
[[ ! -f "$v" ]] && { echo "Unknown version: $v"; exit 1; }

0 commit comments

Comments
 (0)