diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a7f6ac..673a6a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,9 @@ on: - main pull_request: +permissions: + contents: read + jobs: validate: name: Validate (${{ matrix.os }}) @@ -17,7 +20,9 @@ jobs: - macos-latest - ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Install validation tools (macOS) if: runner.os == 'macOS' @@ -39,9 +44,9 @@ jobs: check_bash_version ' bash "$PWD/lib/bash/std/lib_std.sh" - # Hosted runners do not provide Bash 4.2 directly. The BATS suite covers - # the lower-bound comparison logic; this macOS smoke exercises the known - # unsupported system Bash 3.2 path when it is available. + # The exact supported lower bound runs in the bash-42-logging job. This + # macOS smoke exercises the known unsupported system Bash 3.2 path when + # it is available. - name: Smoke unsupported macOS system Bash if: runner.os == 'macOS' run: | @@ -58,3 +63,30 @@ jobs: test "$status" -ne 0 grep -F "requires Bash 4.2 or higher" <<<"$output" ! grep -F "syntax error" <<<"$output" + + bash-42-logging: + name: Logging smoke (Bash 4.2.53) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Run Bash 4.2 logging smoke + run: | + docker run --rm \ + --platform linux/amd64 \ + --network none \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges=true \ + --pids-limit 64 \ + --user 65534:65534 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 \ + --env HOME=/tmp \ + --env TMPDIR=/tmp \ + --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ + --workdir /workspace \ + docker.io/library/bash@sha256:0931edd3941d0603cb3d5da1cb298cf3eb6a579e09e094c3e34e2d5e9df8cddc \ + bash tests/bash-42-logging-smoke.sh 4 2 53 diff --git a/README.md b/README.md index 755f751..98e7163 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,10 @@ The suite expects `bats` and `shellcheck` to be installed. On macOS: brew install bats-core shellcheck ``` +Local validation runs the logging compatibility smoke on the installed +supported Bash. CI runs the same script on the exact minimum runtime, Bash +4.2.53, using a digest-pinned Docker Official Image. + ## Base This repository is managed by [Base](https://github.com/basefoundry/base). diff --git a/tests/bash-42-logging-smoke.sh b/tests/bash-42-logging-smoke.sh new file mode 100755 index 0000000..b2889e4 --- /dev/null +++ b/tests/bash-42-logging-smoke.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash + +smoke_fail() { + printf 'Bash logging smoke failed: %s\n' "$*" >&2 + return 1 +} + +smoke_assert_contains() { + local text="$1" expected="$2" label="$3" + + if [[ "$text" != *"$expected"* ]]; then + smoke_fail "$label did not contain '$expected'." + return 1 + fi + return 0 +} + +smoke_capture_stderr() { + local stdout_path="$1" stderr_path="$2" label="$3" + shift 3 + + if ! "$@" >"$stdout_path" 2>"$stderr_path"; then + smoke_fail "$label returned a failure status." + return 1 + fi + if [[ -s "$stdout_path" ]]; then + smoke_fail "$label wrote to stdout instead of reserving it for program output." + return 1 + fi + SMOKE_CAPTURED_STDERR="$(<"$stderr_path")" + return 0 +} + +smoke_file_mode() { + local file_path="$1" mode + + if mode="$(stat -c '%a' "$file_path" 2>/dev/null)"; then + printf '%s' "$mode" + return 0 + fi + stat -f '%Lp' "$file_path" +} + +main() { + local expected_major="${1-}" expected_minor="${2-}" expected_patch="${3-}" + local script_dir repo_root smoke_dir primary_log payload_file + local terminal_stdout terminal_stderr + local info_output debug_output terminal_output primary_content mode + local utc_output verbose_output + + if (($# != 0 && $# != 3)); then + smoke_fail "usage: $0 [expected-major expected-minor expected-patch]" + return 1 + fi + + if (($# == 3)) && + [[ "${BASH_VERSINFO[0]}" != "$expected_major" || + "${BASH_VERSINFO[1]}" != "$expected_minor" || + "${BASH_VERSINFO[2]}" != "$expected_patch" ]]; then + smoke_fail "expected Bash $expected_major.$expected_minor.$expected_patch; running $BASH_VERSION." + return 1 + fi + + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || { + smoke_fail "unable to resolve the tests directory." + return 1 + } + repo_root="$(cd -- "$script_dir/.." && pwd -P)" || { + smoke_fail "unable to resolve the repository root." + return 1 + } + + unset BASE_CLI_PRIMARY_LOG LOG_DEBUG LOG_UTC NO_COLOR + # shellcheck source=../lib/bash/std/lib_std.sh + if ! source "$repo_root/lib/bash/std/lib_std.sh"; then + smoke_fail "unable to source lib_std.sh." + return 1 + fi + + if ! check_bash_version; then + smoke_fail "the running Bash did not satisfy the supported version check." + return 1 + fi + + std_make_temp_dir smoke_dir bash42-logging-smoke || { + smoke_fail "unable to create the smoke workspace." + return 1 + } + terminal_stdout="$smoke_dir/terminal.stdout" + terminal_stderr="$smoke_dir/terminal.stderr" + primary_log="$smoke_dir/primary.log" + payload_file="$smoke_dir/payload.txt" + + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "default INFO" \ + log_info -l base_bash_libs.smoke "default info" || return 1 + info_output="$SMOKE_CAPTURED_STDERR" + smoke_assert_contains "$info_output" "INFO" "default INFO record" || return 1 + smoke_assert_contains "$info_output" "default info" "default INFO message" || return 1 + + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "default DEBUG" \ + log_debug -l base_bash_libs.smoke "hidden library debug" || return 1 + debug_output="$SMOKE_CAPTURED_STDERR" + if [[ -n "$debug_output" ]]; then + smoke_fail "library DEBUG was visible at the default thresholds." + return 1 + fi + if log_is_enabled -l base_bash_libs.smoke DEBUG; then + smoke_fail "log_is_enabled accepted library DEBUG at the default thresholds." + return 1 + fi + + set_log_level DEBUG || { + smoke_fail "unable to enable terminal DEBUG." + return 1 + } + if log_is_enabled -l base_bash_libs.smoke DEBUG; then + smoke_fail "terminal DEBUG bypassed the base_bash_libs INFO category gate." + return 1 + fi + + set_log_category_level -l base_bash_libs.smoke DEBUG || { + smoke_fail "unable to enable the smoke DEBUG category." + return 1 + } + if ! log_is_enabled -l base_bash_libs.smoke.child DEBUG; then + smoke_fail "a child category did not inherit its parent DEBUG gate." + return 1 + fi + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "enabled DEBUG" \ + log_debug -l base_bash_libs.smoke.child "visible category debug" || return 1 + debug_output="$SMOKE_CAPTURED_STDERR" + smoke_assert_contains "$debug_output" "DEBUG" "enabled DEBUG record" || return 1 + smoke_assert_contains "$debug_output" "visible category debug" "enabled DEBUG message" || return 1 + + set_log_level INFO || { + smoke_fail "unable to restore terminal INFO." + return 1 + } + export BASE_CLI_PRIMARY_LOG="$primary_log" + + if ! log_is_enabled -l base_bash_libs.smoke.child DEBUG; then + smoke_fail "the eligible primary sink did not enable accepted DEBUG." + return 1 + fi + if [[ -e "$primary_log" ]]; then + smoke_fail "log_is_enabled modified the primary sink." + return 1 + fi + + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "persistent DEBUG" \ + log_debug -l base_bash_libs.smoke.child "persisted debug" || return 1 + terminal_output="$SMOKE_CAPTURED_STDERR" + if [[ -n "$terminal_output" ]]; then + smoke_fail "persistent DEBUG leaked to the INFO terminal." + return 1 + fi + primary_content="$(<"$primary_log")" + smoke_assert_contains "$primary_content" "DEBUG" "persistent DEBUG record" || return 1 + smoke_assert_contains "$primary_content" "persisted debug" "persistent DEBUG message" || return 1 + mode="$(smoke_file_mode "$primary_log")" || { + smoke_fail "unable to read the primary-log mode." + return 1 + } + if [[ "$mode" != "600" ]]; then + smoke_fail "primary log mode was $mode instead of 600." + return 1 + fi + + set_log_category_level -l base_bash_libs.smoke INFO || { + smoke_fail "unable to restore the smoke INFO category." + return 1 + } + if log_is_enabled -l base_bash_libs.smoke.child DEBUG; then + smoke_fail "the INFO category gate did not suppress persistent DEBUG." + return 1 + fi + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "blocked persistent DEBUG" \ + log_debug -l base_bash_libs.smoke.child "blocked persistent debug" || return 1 + if [[ -n "$SMOKE_CAPTURED_STDERR" ]]; then + smoke_fail "the INFO category gate allowed terminal DEBUG." + return 1 + fi + primary_content="$(<"$primary_log")" + if [[ "$primary_content" == *"blocked persistent debug"* ]]; then + smoke_fail "the INFO category gate allowed a persistent DEBUG record." + return 1 + fi + + set_log_category_level -l base_bash_libs.smoke DEBUG || { + smoke_fail "unable to re-enable the smoke DEBUG category." + return 1 + } + printf 'persistent file contents\n' >"$payload_file" || { + smoke_fail "unable to create the file-logging payload." + return 1 + } + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "persistent DEBUG file" \ + log_debug_file -l base_bash_libs.smoke.child "$payload_file" || return 1 + terminal_output="$SMOKE_CAPTURED_STDERR" + if [[ -n "$terminal_output" ]]; then + smoke_fail "persistent DEBUG file contents leaked to the INFO terminal." + return 1 + fi + primary_content="$(<"$primary_log")" + smoke_assert_contains "$primary_content" "Contents of file '$payload_file':" \ + "persistent file header" || return 1 + smoke_assert_contains "$primary_content" "persistent file contents" \ + "persistent file payload" || return 1 + + export LOG_UTC=1 + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "UTC INFO" \ + log_info -l base_bash_libs.smoke "utc info" || return 1 + utc_output="$SMOKE_CAPTURED_STDERR" + smoke_assert_contains "$utc_output" " UTC INFO" "UTC log timestamp" || return 1 + + set_log_level VERBOSE || { + smoke_fail "unable to enable VERBOSE compatibility." + return 1 + } + set_log_category_level -l base_bash_libs.smoke VERBOSE || { + smoke_fail "unable to enable the VERBOSE compatibility category." + return 1 + } + smoke_capture_stderr "$terminal_stdout" "$terminal_stderr" "VERBOSE compatibility" \ + log_verbose -l base_bash_libs.smoke "compat verbose" || return 1 + verbose_output="$SMOKE_CAPTURED_STDERR" + smoke_assert_contains "$verbose_output" "VERBOSE" "VERBOSE compatibility record" || return 1 + if [[ "$verbose_output" == *"deprecated"* ]]; then + smoke_fail "VERBOSE compatibility emitted a runtime deprecation warning." + return 1 + fi + + printf 'Bash logging smoke passed on Bash %s.\n' "$BASH_VERSION" + return 0 +} + +main "$@" diff --git a/tests/lint-warnings.sh b/tests/lint-warnings.sh index 1404f1c..aa9d877 100755 --- a/tests/lint-warnings.sh +++ b/tests/lint-warnings.sh @@ -20,6 +20,7 @@ run_stage() { lint_files=( bin/base-bash + tests/bash-42-logging-smoke.sh tests/validate.sh tests/lint-warnings.sh examples/std-usage.sh diff --git a/tests/validate.sh b/tests/validate.sh index 5600346..4779da8 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -15,6 +15,7 @@ required_files=( .github/workflows/project-intake.yml .github/workflows/tests.yml bin/base-bash + tests/bash-42-logging-smoke.sh examples/std-usage.sh examples/cookbook-cleanup-temp.sh examples/cookbook-args-lists-strings.sh @@ -63,6 +64,7 @@ check_no_strict_mode() { local file matches status local strict_mode_files=( bin/base-bash + tests/bash-42-logging-smoke.sh tests/validate.sh tests/lint-warnings.sh examples/*.sh @@ -191,6 +193,7 @@ done run_stage "ShellCheck error profile" shellcheck --severity=error \ bin/base-bash \ + tests/bash-42-logging-smoke.sh \ tests/validate.sh \ tests/lint-warnings.sh \ examples/std-usage.sh \ @@ -220,6 +223,7 @@ bats_files=( run_stage "BATS test suites" bats \ "${bats_files[@]}" || exit $? +run_stage "Bash logging smoke" tests/bash-42-logging-smoke.sh || exit $? run_stage "examples/std-usage.sh" examples/std-usage.sh >/dev/null || exit $? run_stage "examples/cookbook-cleanup-temp.sh" examples/cookbook-cleanup-temp.sh >/dev/null || exit $? run_stage "examples/cookbook-args-lists-strings.sh" examples/cookbook-args-lists-strings.sh >/dev/null || exit $?