diff --git a/scripts/ci/build-host.sh b/scripts/ci/build-host.sh index 4a09016..2cb72c2 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. @@ -194,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. @@ -221,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")") @@ -268,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 \ @@ -319,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 @@ -339,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'