diff --git a/.github/workflows/bash32-script-compat.yml b/.github/workflows/bash32-script-compat.yml new file mode 100644 index 000000000..c6eab8ad2 --- /dev/null +++ b/.github/workflows/bash32-script-compat.yml @@ -0,0 +1,42 @@ +name: Bash 3.2 script compatibility + +on: + push: + branches: [ main ] + paths: &script-paths + - bin/check-bash32 + - bin/docker-run + - bin/docker-user-args + - bin/normalize-sdk-modes + - bin/php + - bin/php-in-docker + - bin/sandbox-dotnet + - bin/sandbox-java + - bin/sandbox-node + - bin/sandbox-ruby + - sdks/*/bin/dotnet + - sdks/*/bin/gradle + - sdks/*/bin/node + - sdks/*/bin/php + - sdks/*/bin/php-8 + - sdks/*/bin/python + - sdks/*/bin/ruby + - sdks/*/run-build + - .github/workflows/bash32-script-compat.yml + pull_request: + branches: [ main ] + paths: *script-paths + +permissions: + contents: read + +jobs: + bash32: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: Check SDK scripts on Bash 3.2 + run: ./bin/check-bash32 diff --git a/bin/check-bash32 b/bin/check-bash32 new file mode 100755 index 000000000..e469f9b1a --- /dev/null +++ b/bin/check-bash32 @@ -0,0 +1,258 @@ +#!/usr/bin/env bash + +# macOS ships Bash 3.2. SDK Docker wrappers must keep running there. +# This re-executes under the bash:3.2 image, parses the wrappers, rejects +# Bash 4 syntax that `bash -n` cannot see, and runs them with docker stubbed. + +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) + +if [[ "${CHECK_BASH32_INNER:-}" != "1" ]]; then + exec docker run --rm \ + -v "${ROOT}:/work" \ + -w /work \ + -e CHECK_BASH32_INNER=1 \ + "${BASH32_IMAGE:-bash:3.2}" \ + bash bin/check-bash32 +fi + +if [[ "${BASH_VERSINFO[0]}" -ne 3 || "${BASH_VERSINFO[1]}" -ne 2 ]]; then + printf 'expected Bash 3.2, found %s\n' "${BASH_VERSION}" >&2 + exit 1 +fi + +# Bash 4+ tokens. `bash -n` accepts `mapfile` on Bash 3.2 because it is +# just a command name; the shell only fails when that line runs. +bash4_pattern='(^|[[:space:]])(mapfile|readarray|coproc)([[:space:]]|$)|declare[[:space:]]+-A|\[\[[[:space:]]+-v[[:space:]]|\$\{[A-Za-z_][A-Za-z0-9_]*(,,|\^\^)|\$\{[A-Za-z_][A-Za-z0-9_]*@[A-Za-z]|\$\{[^}]*\[-[0-9]+\]|&>>|[|][&]' + +scripts=( + bin/docker-run + bin/docker-user-args + bin/normalize-sdk-modes + bin/php + bin/php-in-docker + bin/sandbox-dotnet + bin/sandbox-java + bin/sandbox-node + bin/sandbox-ruby + sdks/dotnet/bin/dotnet + sdks/dotnet/bin/php + sdks/dotnet/run-build + sdks/java-v1/bin/gradle + sdks/java-v1/bin/php + sdks/java-v1/run-build + sdks/java-v2/bin/gradle + sdks/java-v2/bin/php + sdks/java-v2/run-build + sdks/node/bin/node + sdks/node/bin/php + sdks/node/run-build + sdks/php/bin/php + sdks/php/bin/php-8 + sdks/php/run-build + sdks/python/bin/php + sdks/python/bin/python + sdks/python/run-build + sdks/ruby/bin/php + sdks/ruby/bin/ruby + sdks/ruby/run-build +) + +reject_bash4() { + local script="$1" + local line + line=$(grep -nE "${bash4_pattern}" "${script}" | grep -vE '^[0-9]+:[[:space:]]*#' || true) + if [[ -n "${line}" ]]; then + printf 'Bash 4+ syntax in %s:\n%s\n' "${script}" "${line}" >&2 + return 1 + fi +} + +self_test_denylist() { + local tmp + tmp=$(mktemp) + printf '#!/usr/bin/env bash\n# mapfile stays in this comment\necho ok\n' > "${tmp}" + reject_bash4 "${tmp}" + printf '#!/usr/bin/env bash\nmapfile -t xs < /dev/null\n' > "${tmp}" + if reject_bash4 "${tmp}" 2>/dev/null; then + rm -f "${tmp}" + printf 'denylist did not reject mapfile\n' >&2 + exit 1 + fi + rm -f "${tmp}" +} + +assert_log_contains() { + local needle="$1" + if ! grep -Fxq -e "${needle}" "${DOCKER_RUN_LOG}"; then + printf 'expected docker argument <%s>\nactual:\n' "${needle}" >&2 + cat "${DOCKER_RUN_LOG}" >&2 + exit 1 + fi +} + +assert_log_excludes() { + local needle="$1" + if grep -Fxq -e "${needle}" "${DOCKER_RUN_LOG}"; then + printf 'unexpected docker argument <%s>\nactual:\n' "${needle}" >&2 + cat "${DOCKER_RUN_LOG}" >&2 + exit 1 + fi +} + +run_docker_wrapper() { + : > "${DOCKER_RUN_LOG}" + if ! "$@"; then + printf 'command failed:' >&2 + printf ' %s' "$@" >&2 + printf '\n' >&2 + exit 1 + fi +} + +self_test_denylist + +for script in "${scripts[@]}"; do + if ! bash -n "${script}"; then + printf 'Bash 3.2 rejected %s\n' "${script}" >&2 + exit 1 + fi + reject_bash4 "${script}" +done + +work=$(mktemp -d) +stubdir=$(mktemp -d) +trap 'rm -rf "${work}" "${stubdir}"' EXIT + +for script in "${scripts[@]}"; do + mkdir -p "${work}/$(dirname "${script}")" + cp "${script}" "${work}/${script}" +done + +cat > "${stubdir}/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "info" ]]; then + if [[ -n "${DOCKER_INFO_SECURITY:-}" ]]; then + printf '%s\n' "${DOCKER_INFO_SECURITY}" + fi + exit 0 +fi +if [[ "${1:-}" == "run" ]]; then + printf '%s\n' "$@" >> "${DOCKER_RUN_LOG:?}" + exit 0 +fi +printf 'unexpected docker command: %s\n' "$*" >&2 +exit 1 +EOF +chmod +x "${stubdir}/docker" + +export PATH="${stubdir}:${PATH}" +DOCKER_RUN_LOG=$(mktemp) +export DOCKER_RUN_LOG +unset DOCKER_RUN_AS_ROOT DOCKER_INFO_SECURITY PHP_DOCKER_PLATFORM PHP_USE_NATIVE GITHUB_ACTIONS + +host_user="$(id -u):$(id -g)" + +user_args=$("${work}/bin/docker-user-args") +expected=$(printf '%s\n' --user "${host_user}") +if [[ "${user_args}" != "${expected}" ]]; then + printf 'rootful docker-user-args returned:\n%s\n' "${user_args}" >&2 + exit 1 +fi + +user_args=$(DOCKER_RUN_AS_ROOT=1 "${work}/bin/docker-user-args") +expected=$(printf '%s\n' --user root:root) +if [[ "${user_args}" != "${expected}" ]]; then + printf 'DOCKER_RUN_AS_ROOT docker-user-args returned:\n%s\n' "${user_args}" >&2 + exit 1 +fi + +user_args=$(DOCKER_INFO_SECURITY=name=rootless "${work}/bin/docker-user-args") +if [[ "${user_args}" != "${expected}" ]]; then + printf 'rootless docker-user-args returned:\n%s\n' "${user_args}" >&2 + exit 1 +fi + +run_docker_wrapper "${work}/bin/docker-run" echo ok +assert_log_contains --rm +assert_log_contains --user +assert_log_contains "${host_user}" +assert_log_contains echo +assert_log_contains ok + +DOCKER_RUN_AS_ROOT=1 run_docker_wrapper "${work}/bin/docker-run" echo ok +assert_log_contains root:root + +runtime=( + bin/php + sdks/dotnet/bin/dotnet + sdks/dotnet/bin/php + sdks/java-v1/bin/gradle + sdks/java-v1/bin/php + sdks/java-v2/bin/gradle + sdks/java-v2/bin/php + sdks/node/bin/node + sdks/node/bin/php + sdks/php/bin/php + sdks/php/bin/php-8 + sdks/python/bin/php + sdks/python/bin/python + sdks/ruby/bin/php + sdks/ruby/bin/ruby +) + +for script in "${runtime[@]}"; do + run_docker_wrapper "${work}/${script}" true + assert_log_contains --user + assert_log_contains "${host_user}" + assert_log_contains true +done + +run_docker_wrapper "${work}/bin/php" composer install +assert_log_excludes --platform +assert_log_contains jtreminio/php-cli:7.4 + +run_docker_wrapper "${work}/sdks/node/bin/php" composer install +assert_log_excludes --platform + +run_docker_wrapper "${work}/sdks/php/bin/php" php -v +assert_log_contains --platform +assert_log_contains linux/amd64 +assert_log_contains jtreminio/php-cli:8.1 + +run_docker_wrapper "${work}/sdks/php/bin/php-8" php -v +assert_log_contains --platform +assert_log_contains jtreminio/php:8.1 + +run_docker_wrapper "${work}/sdks/dotnet/bin/dotnet" dotnet test +assert_log_contains mcr.microsoft.com/dotnet/sdk:10.0 + +: > "${DOCKER_RUN_LOG}" +PHP_USE_NATIVE=1 "${work}/sdks/php/bin/php" true +if [[ -s "${DOCKER_RUN_LOG}" ]]; then + printf 'PHP_USE_NATIVE=1 invoked docker:\n' >&2 + cat "${DOCKER_RUN_LOG}" >&2 + exit 1 +fi + +sample=$(mktemp) +printf 'x\n' > "${sample}" +chmod 755 "${sample}" +"${work}/bin/normalize-sdk-modes" "${sample}" +mode=$(stat -c %a "${sample}") +if [[ "${mode}" != "644" ]]; then + printf 'normalize-sdk-modes left mode %s\n' "${mode}" >&2 + exit 1 +fi +chmod 755 "${sample}" +GITHUB_ACTIONS=1 "${work}/bin/normalize-sdk-modes" "${sample}" +mode=$(stat -c %a "${sample}") +if [[ "${mode}" != "755" ]]; then + printf 'normalize-sdk-modes changed mode to %s on GitHub Actions\n' "${mode}" >&2 + exit 1 +fi +rm -f "${sample}" + +printf 'Bash %s accepted the SDK Docker scripts\n' "${BASH_VERSION}" diff --git a/bin/docker-run b/bin/docker-run index 8b824bd53..d7629112d 100755 --- a/bin/docker-run +++ b/bin/docker-run @@ -6,6 +6,9 @@ set -euo pipefail DIR=$(cd "$(dirname "$0")" && pwd) -mapfile -t DOCKER_USER_ARGS < <("${DIR}/docker-user-args") +DOCKER_USER_ARGS=() +while IFS= read -r arg; do + DOCKER_USER_ARGS+=("$arg") +done < <("${DIR}/docker-user-args") exec docker run --rm "${DOCKER_USER_ARGS[@]}" "$@" diff --git a/bin/php-in-docker b/bin/php-in-docker index 5bbe90912..de0916679 100755 --- a/bin/php-in-docker +++ b/bin/php-in-docker @@ -14,16 +14,17 @@ shift 3 mkdir -p "${ROOT_DIR}/.docker/composer" -platform=() +# Keep this array non-empty. Bash 3.2 with set -u errors on an empty +# "${array[@]}", and that is the Bash macOS ships. +docker_args=( + -v "${ROOT_DIR}:${WORKING_DIR}" + -v "${ROOT_DIR}/.docker/composer:/tmp/composer" + -e COMPOSER_HOME=/tmp/composer + -w "${WORKING_DIR}" + "${IMAGE}" +) if [[ -n "${PHP_DOCKER_PLATFORM:-}" ]]; then - platform=(--platform "${PHP_DOCKER_PLATFORM}") + docker_args=(--platform "${PHP_DOCKER_PLATFORM}" "${docker_args[@]}") fi -exec "${DIR}/docker-run" \ - "${platform[@]}" \ - -v "${ROOT_DIR}:${WORKING_DIR}" \ - -v "${ROOT_DIR}/.docker/composer:/tmp/composer" \ - -e COMPOSER_HOME=/tmp/composer \ - -w "${WORKING_DIR}" \ - "${IMAGE}" \ - "$@" +exec "${DIR}/docker-run" "${docker_args[@]}" "$@"