From 65d65a085271f20f1282a3433d88a7e4630d06d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Jos=C3=A9=20Garc=C3=ADa=20Garc=C3=ADa?= Date: Wed, 26 Aug 2026 00:07:38 +0200 Subject: [PATCH 1/2] Let a host that needs no launcher run its own smoke test Every macOS leg of the executor has been dying since the Rosetta gate landed, after a complete and valid build: the SDK is finalized, the static validation passes, the toolchain contract passes, and then build-host.sh exits with `run[@]: unbound variable` and stages nothing. The upload finds an empty out/ and the Intel host, which needs the arm64 SDK to cross from, fails behind it. arm64-apple-darwin is a required host, so no snapshot could publish at all. The launcher prefix is an array that is empty for every host that runs its own binaries -- as the comment above it says -- and macOS's /bin/bash is 3.2, where expanding an empty array under `set -u` is an error rather than nothing: $ /bin/bash -c 'set -u; run=(); "${run[@]}" echo hola' /bin/bash: run[@]: unbound variable $ /bin/bash -c 'set -u; run=(); ${run[@]+"${run[@]}"} echo hola' hola build_and_stage() already carries the tolerant form, and the reason for it, three lines below. --- scripts/ci/build-host.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/ci/build-host.sh b/scripts/ci/build-host.sh index 4a09016..d4c5b64 100755 --- a/scripts/ci/build-host.sh +++ b/scripts/ci/build-host.sh @@ -157,12 +157,15 @@ smoke_test_bootstrap() { local -a run=(${2:-}) digest=$(awk '{print $1}' "$bootstrap_archive.sha256") install_root="$PWD/bootstrap-installed" + # ${run[@]+...}: macOS's /bin/bash is 3.2, where expanding an empty array + # under set -u is an unbound-variable error -- and empty is the normal + # case here, for every host that runs its own binaries. VITASDK_BOOTSTRAP_ARCHIVE="$bootstrap_archive" VITASDK_BOOTSTRAP_SHA256="$digest" \ - "${run[@]}" build/vitasdk/share/vdpm/bootstrap-vitasdk.sh --install-dir "$install_root" - VITASDK="$install_root" "${run[@]}" "$install_root/bin/vdpm" --help >/dev/null + ${run[@]+"${run[@]}"} build/vitasdk/share/vdpm/bootstrap-vitasdk.sh --install-dir "$install_root" + VITASDK="$install_root" ${run[@]+"${run[@]}"} "$install_root/bin/vdpm" --help >/dev/null # vdpm ships pacman under libexec/vdpm, not bin/. - "${run[@]}" "$install_root/libexec/vdpm/pacman" --version >/dev/null - "${run[@]}" "$install_root/bin/arm-vita-eabi-gcc" --version + ${run[@]+"${run[@]}"} "$install_root/libexec/vdpm/pacman" --version >/dev/null + ${run[@]+"${run[@]}"} "$install_root/bin/arm-vita-eabi-gcc" --version } # Builds against $stage1_dir if set, then stages outputs plus provenance. From d94e121a0b44c788746d46d5a3bd7bcc835d1aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Jos=C3=A9=20Garc=C3=ADa=20Garc=C3=ADa?= Date: Wed, 26 Aug 2026 00:10:51 +0200 Subject: [PATCH 2/2] Make the bash 3.2 array rule mechanical instead of remembered This has now bitten twice in the same file: fixed once in "Expand possibly-empty arrays the way macOS's bash 3.2 tolerates", reintroduced with the Rosetta launcher, and both times it cost a full macOS build that finished, validated, and then staged nothing. Deciding case by case which array can be empty is the judgement that failed, so the remaining expansions in the scripts that run on a published host all take the tolerant form -- it means the same thing as the plain one whenever the array has elements -- and a test refuses any that do not. Against the parent of this branch it names lines 161 to 165 of build-host.sh, the ones that broke the nightly. The test is static because it has to be: BASH_COMPAT=32 does not restore the 3.2 behaviour, so nothing running on a Linux runner can reproduce it by executing anything. --- scripts/ci/build-host.sh | 16 +++++----- scripts/create-bootstrap-archive.sh | 2 +- scripts/create-core-package.sh | 2 +- scripts/create-core-repositories.sh | 12 +++---- scripts/install-vdpm-bundle.sh | 2 +- tests/ci/test-empty-array-expansion.sh | 43 ++++++++++++++++++++++++++ 6 files changed, 60 insertions(+), 17 deletions(-) create mode 100755 tests/ci/test-empty-array-expansion.sh diff --git a/scripts/ci/build-host.sh b/scripts/ci/build-host.sh index d4c5b64..2cb72c2 100755 --- a/scripts/ci/build-host.sh +++ b/scripts/ci/build-host.sh @@ -197,8 +197,8 @@ build_and_stage() { targets+=(core-package bootstrap-archive) fi - cmake "${cmake_args[@]}" - cmake --build build --target "${targets[@]}" --parallel "$(ci_nproc)" + cmake ${cmake_args[@]+"${cmake_args[@]}"} + cmake --build build --target ${targets[@]+"${targets[@]}"} --parallel "$(ci_nproc)" report_ccache_statistics # Verified wherever it can be run, which is not the same as natively. @@ -224,7 +224,7 @@ stage_and_write_provenance() { fi local -a names=() local file - for file in "${produced[@]}"; do + for file in ${produced[@]+"${produced[@]}"}; do [[ -f $file ]] || continue cp "$file" "$out_dir/" names+=("$(basename "$file")") @@ -271,7 +271,7 @@ build_musl_host() { # so the container reaches the same cache the runner restored. docker run --rm \ -v "$PWD":/src -w /src \ - "${docker_env[@]}" \ + ${docker_env[@]+"${docker_env[@]}"} \ alpine:3.20 sh -eux -c ' apk add --no-cache bash build-base cmake git autoconf automake \ libtool libarchive-tools texinfo bison flex pkgconf curl xz \ @@ -322,12 +322,12 @@ install_dependencies() { i686-w64-mingw32) extra=(g++-mingw-w64-i686) ;; x86_64-unknown-freebsd | aarch64-unknown-freebsd) extra=(clang lld llvm) ;; esac - "${sudo_cmd[@]}" apt-get update -qq - "${sudo_cmd[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + ${sudo_cmd[@]+"${sudo_cmd[@]}"} apt-get update -qq + ${sudo_cmd[@]+"${sudo_cmd[@]}"} env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ cmake cmake-data git build-essential autoconf automake libtool \ texinfo bison flex pkg-config python3 python3-pip curl bzip2 xz-utils \ libarchive-tools ccache \ - "${extra[@]}" + ${extra[@]+"${extra[@]}"} pip3 install --quiet cmake==3.31.6 ;; esac @@ -342,7 +342,7 @@ enable_ccache() { shims="$(brew --prefix ccache)/libexec" else [[ -x /usr/sbin/update-ccache-symlinks ]] && - "${sudo_cmd[@]}" /usr/sbin/update-ccache-symlinks + ${sudo_cmd[@]+"${sudo_cmd[@]}"} /usr/sbin/update-ccache-symlinks shims=/usr/lib/ccache fi [[ -d $shims ]] || { diff --git a/scripts/create-bootstrap-archive.sh b/scripts/create-bootstrap-archive.sh index a06658e..c0fc9c4 100755 --- a/scripts/create-bootstrap-archive.sh +++ b/scripts/create-bootstrap-archive.sh @@ -31,7 +31,7 @@ case $host in bin/include/refresh-repositories.sh) ;; esac -for relative_path in "${required[@]}"; do +for relative_path in ${required[@]+"${required[@]}"}; do [[ -e $sdk_root/$relative_path ]] || { printf 'bootstrap SDK is missing %s\n' "$relative_path" >&2 exit 1 diff --git a/scripts/create-core-package.sh b/scripts/create-core-package.sh index 531818b..f352d87 100755 --- a/scripts/create-core-package.sh +++ b/scripts/create-core-package.sh @@ -97,7 +97,7 @@ cp -a "$sdk_root/." "$core_root/" rm -f "$core_root/.PKGINFO" "$core_root/.BUILDINFO" "$core_root/.MTREE" \ "$core_root/etc/pacman.conf" -for path in "${client_paths[@]}"; do +for path in ${client_paths[@]+"${client_paths[@]}"}; do [[ -e $core_root/$path ]] || { printf 'the SDK does not carry %s\n' "$path" >&2 exit 1 diff --git a/scripts/create-core-repositories.sh b/scripts/create-core-repositories.sh index 9127989..ea0743a 100755 --- a/scripts/create-core-repositories.sh +++ b/scripts/create-core-repositories.sh @@ -93,13 +93,13 @@ normalize_database() { } mapfile -t sorted_architectures < <(printf '%s\n' "${!architectures[@]}" | LC_ALL=C sort) -for architecture in "${sorted_architectures[@]}"; do +for architecture in ${sorted_architectures[@]+"${sorted_architectures[@]}"}; do read -r -a package_filenames <<<"${architectures[$architecture]}" packages=() - for package_filename in "${package_filenames[@]}"; do + for package_filename in ${package_filenames[@]+"${package_filenames[@]}"}; do packages+=("$staging_directory/$package_filename") done - repo-add "$staging_directory/$architecture.db.tar.gz" "${packages[@]}" + repo-add "$staging_directory/$architecture.db.tar.gz" ${packages[@]+"${packages[@]}"} normalize_database "$staging_directory/$architecture.db.tar.gz" \ "$temporary_directory/$architecture.db" normalize_database "$staging_directory/$architecture.files.tar.gz" \ @@ -177,10 +177,10 @@ if [[ -n ${RELEASE_SCHEMA:-}${RELEASE_BUILD_ID:-}${RELEASE_BUILDSCRIPTS_REVISION } host_fragments=() - for architecture in "${sorted_architectures[@]}"; do + for architecture in ${sorted_architectures[@]+"${sorted_architectures[@]}"}; do host_artifacts=("$architecture.db" "$architecture.files") read -r -a package_filenames <<<"${architectures[$architecture]}" - host_artifacts+=("${package_filenames[@]}") + host_artifacts+=(${package_filenames[@]+"${package_filenames[@]}"}) while IFS= read -r -d '' extra; do host_artifacts+=("$(basename "$extra")") done < <(find "$staging_directory" -maxdepth 1 -type f \ @@ -191,7 +191,7 @@ if [[ -n ${RELEASE_SCHEMA:-}${RELEASE_BUILD_ID:-}${RELEASE_BUILDSCRIPTS_REVISION printf 'no provenance echo found for published host: %s\n' "$architecture" >&2 exit 1 } - host_fragments+=("{\"name\":\"$(json_escape "$architecture")\",\"build_id\":\"$(json_escape "$build_id")\",\"artifacts\":$(json_string_array "${host_artifacts[@]}")}") + host_fragments+=("{\"name\":\"$(json_escape "$architecture")\",\"build_id\":\"$(json_escape "$build_id")\",\"artifacts\":$(json_string_array ${host_artifacts[@]+"${host_artifacts[@]}"})}") done hosts_joined=$( diff --git a/scripts/install-vdpm-bundle.sh b/scripts/install-vdpm-bundle.sh index 64aabd2..920b05a 100755 --- a/scripts/install-vdpm-bundle.sh +++ b/scripts/install-vdpm-bundle.sh @@ -64,7 +64,7 @@ else bin/include/refresh-repositories.sh ) fi -for relative_path in "${required[@]}"; do +for relative_path in ${required[@]+"${required[@]}"}; do [[ -f $root/$relative_path && ! -L $root/$relative_path ]] || { printf 'vdpm bundle is missing required regular file: %s\n' "$relative_path" >&2 exit 1 diff --git a/tests/ci/test-empty-array-expansion.sh b/tests/ci/test-empty-array-expansion.sh new file mode 100755 index 0000000..8177596 --- /dev/null +++ b/tests/ci/test-empty-array-expansion.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Arrays that reach a published host must expand the way bash 3.2 tolerates. +# +# macOS runners are the reason. Their /bin/bash is 3.2, where expanding an +# empty array under `set -u` is an unbound-variable error rather than +# nothing, and the failure comes after a complete build: the SDK is +# finalized and validated, then the script dies and stages nothing. It has +# happened twice -- once fixed in "Expand possibly-empty arrays the way +# macOS's bash 3.2 tolerates", then reintroduced with the Rosetta launcher, +# which is empty for every host that runs its own binaries. +# +# The rule is mechanical on purpose. Deciding case by case which array can +# be empty is exactly the judgement that failed: `${name[@]+"${name[@]}"}` +# means the same thing as `"${name[@]}"` whenever the array has elements, +# so requiring it everywhere costs nothing and needs no judgement. + +set -euo pipefail + +repository_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P) + +offenders=$(python3 - "$repository_root" <<'PYEOF' +import glob, os, re, sys + +root = sys.argv[1] +# Value expansions only: ${#name[@]} and ${!name[@]} are fine in 3.2. +plain = re.compile(r'"\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"') +tolerant = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}') + +for path in sorted(glob.glob(os.path.join(root, "scripts", "**", "*.sh"), recursive=True)): + with open(path, encoding="utf-8") as handle: + for number, line in enumerate(handle, 1): + if plain.search(tolerant.sub("", line)): + print(f"{os.path.relpath(path, root)}:{number}:{line.rstrip()}") +PYEOF +) + +if [[ -n $offenders ]]; then + printf 'these expansions die on macOS bash 3.2 when the array is empty;\n' >&2 + printf 'write ${name[@]+"${name[@]}"} instead:\n\n%s\n' "$offenders" >&2 + exit 1 +fi + +printf 'empty-array expansion: all checks passed\n'