diff --git a/.github/workflows/issue-branch-policy.yml b/.github/workflows/issue-branch-policy.yml index 3d7929e..f6cde5e 100644 --- a/.github/workflows/issue-branch-policy.yml +++ b/.github/workflows/issue-branch-policy.yml @@ -302,6 +302,7 @@ jobs: ;; esac + # shellcheck disable=SC2153 # This is an intentional workflow-level env input. if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]]; then fail_policy "$target_head_sha" "Pull request number must be a positive integer" || true exit 1 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..218c00a --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,79 @@ +name: Quality + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +concurrency: + group: quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Quality gates + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install ShellCheck + run: sudo apt-get install -y shellcheck + + - name: Enforce warning ShellCheck profile + run: ./tests/lint-warnings.sh + + - name: Enforce repository quality contract + run: ./tests/quality-contract.sh + + - name: Enforce shfmt profile + run: | + if [[ -n "${GITHUB_BASE_REF:-}" ]]; then + format_base="origin/$GITHUB_BASE_REF" + else + format_base='HEAD^' + fi + mapfile -t format_files < <( + git diff --name-only "$format_base"...HEAD -- \ + '*.sh' '*.bash' 'bin/base-bash' 'scripts/api-manifest' \ + 'scripts/first-party-cutover' 'scripts/library-bundle' \ + 'scripts/migrate-v2-symbols' 'scripts/release' 'scripts/vendor' \ + 'tests/fixtures/basectl-release-stub' + ) + ((${#format_files[@]} > 0)) || { + echo 'No changed Bash sources require formatting.' + exit 0 + } + docker run --rm \ + --network none \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges=true \ + --user 65534:65534 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 \ + --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ + --workdir /workspace \ + docker.io/mvdan/shfmt@sha256:44154be4ec86510d0d98d6eae0cd9d2927bcf8499791fdbee1fa2009b1b32589 \ + -d -ln bash -i 4 -sr "${format_files[@]}" + + - name: Lint GitHub workflows + run: | + docker run --rm \ + --network none \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges=true \ + --user 65534:65534 \ + --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ + --workdir /workspace \ + docker.io/rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7 \ + -color diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c38ea08..464bcb8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: validate: name: Validate (${{ matrix.os }}) @@ -17,8 +21,8 @@ jobs: fail-fast: false matrix: os: - - macos-latest - - ubuntu-latest + - macos-14 + - ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -99,6 +103,52 @@ jobs: bash tests/benchmark-contract.sh ' + representative-bash: + name: Compatibility (Bash ${{ matrix.version }}) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - version: 4.4.23 + image: docker.io/library/bash@sha256:43ad196f649f1a759d7d5a18c771f2d87290051b4ae053b6eadb0a8b72dbd4eb + - version: 5.0.18 + image: docker.io/library/bash@sha256:2ec6d5dd02c732f425fe18c4bd5f02065381ec0083311dffcfb7bad62430e88f + - version: 5.2.37 + image: docker.io/library/bash@sha256:534a5f1d11652aadaa9f08838f6637ac11a46a8b4b736a4cbf09c5945e38516f + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Run representative Bash contract + env: + BASH_IMAGE: ${{ matrix.image }} + run: | + docker run --rm \ + --platform linux/amd64 \ + --network none \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges=true \ + --pids-limit 128 \ + --user 65534:65534 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=32m,mode=1777 \ + --env HOME=/tmp \ + --env TMPDIR=/tmp \ + --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ + --workdir /workspace \ + "$BASH_IMAGE" \ + bash -c ' + set -e + bash tests/bash-option-contract.sh + bash tests/property-contract.sh + bash tests/artifact-contract.sh + bash tests/benchmark-contract.sh + bash tests/concurrency-contract.sh + ' + release-gates: name: Release gates (matrix and provenance) runs-on: ubuntu-24.04 diff --git a/benchmarks/reference-apps.sh b/benchmarks/reference-apps.sh index 8578caf..cf31682 100755 --- a/benchmarks/reference-apps.sh +++ b/benchmarks/reference-apps.sh @@ -5,21 +5,37 @@ launcher="$repo_root/bin/base-bash" framework="$repo_root/lib/bash" iterations="${BASE_REFERENCE_BENCHMARK_ITERATIONS:-10}" -[[ "$iterations" =~ ^[1-9][0-9]*$ ]] || { printf 'iterations must be positive\n' >&2; exit 2; } +[[ "$iterations" =~ ^[1-9][0-9]*$ ]] || { + printf 'iterations must be positive\n' >&2 + exit 2 +} + +benchmark_timestamp_ns() { + local timestamp + + timestamp="$(date +%s%N 2> /dev/null || true)" + if [[ "$timestamp" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$timestamp" + return 0 + fi + + timestamp="$(date +%s 2> /dev/null)" || return 1 + printf '%s000000000\n' "$timestamp" +} printf 'benchmark_schema=1\n' printf 'bash=%s\n' "$BASH_VERSION" printf 'os=%s\n' "$(uname -s)" printf 'iterations=%s\n' "$iterations" -printf 'framework_commit=%s\n' "$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || printf unknown)" +printf 'framework_commit=%s\n' "$(git -C "$repo_root" rev-parse HEAD 2> /dev/null || printf unknown)" for app in installer release-helper ops-cli; do - start_ns="$(date +%s%N 2>/dev/null || date +%s000000000)" + start_ns="$(benchmark_timestamp_ns)" || exit 1 for ((index = 0; index < iterations; index++)); do BASE_BASH_LIBS_DIR="$framework" "$launcher" \ - "$repo_root/examples/reference-apps/$app/bin/app" --help >/dev/null || exit $? + "$repo_root/examples/reference-apps/$app/bin/app" --help > /dev/null || exit $? done - end_ns="$(date +%s%N 2>/dev/null || date +%s000000000)" + end_ns="$(benchmark_timestamp_ns)" || exit 1 elapsed_ns=$((end_ns - start_ns)) printf 'app=%s\tstartup_help_total_ns=%s\tstartup_help_avg_ns=%s\n' \ "$app" "$elapsed_ns" "$((elapsed_ns / iterations))" diff --git a/docs/README.md b/docs/README.md index f3c7321..16f764d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ when updating an existing script. - [API reference](api-reference.md) and [v2 API contract](v2-api-contract.md) - [Configuration, lifecycle, and status contracts](v2/architecture.md#application-contract) - [Testing, vendoring, bundling, and release](v2/architecture.md#delivery) +- [CI and default-branch policy](ci-policy.md) - [Support and threat model](support-policy.md) ยท [security reporting](../SECURITY.md) The quickstart is deliberately pinned to an immutable release ref. It never diff --git a/docs/ci-policy.md b/docs/ci-policy.md new file mode 100644 index 0000000..75b85ea --- /dev/null +++ b/docs/ci-policy.md @@ -0,0 +1,50 @@ +# CI and default-branch policy + +The `Tests` and `Quality` workflows are release gates, not advisory examples. +They run with `contents: read`, cancel superseded runs, and keep network access +out of the containerized compatibility and lint checks. Actions and container +images are pinned to full immutable commit or digest references; changing one +requires a reviewable dependency update. + +The shfmt gate checks every Bash source changed by a pull request (and the +latest commit on `main`). This prevents new formatting debt while allowing the +existing v1-to-v2 codebase to be cleaned incrementally; touching a legacy file +puts its complete contents under the formatter gate. + +## Required checks + +The `main` branch must require these checks before merge: + +- `Tests / Validate (macos-14)` +- `Tests / Validate (ubuntu-24.04)` +- `Tests / Compatibility (Bash 4.4.23)` +- `Tests / Compatibility (Bash 5.0.18)` +- `Tests / Compatibility (Bash 5.2.37)` +- `Tests / Compatibility smoke (Bash 4.2.53)` +- `Tests / Release gates (matrix and provenance)` +- `Quality / Quality gates` +- `Issue Branch Policy / Publish issue branch policy` + +Require one approving review, dismiss stale approvals after new commits, and +require branches to be up to date before merging. Administrators should keep +the protection enforced; an emergency merge is an auditable exception, not a +replacement for the required checks. + +## Emergency procedure + +1. Record the incident, affected commit, approver, and reason in the pull + request and the umbrella issue. +2. Use an administrator-only bypass only for a time-sensitive remediation. +3. Restore branch protection immediately and run the complete workflows on the + resulting `main` commit. +4. Open a follow-up issue for every skipped check or review, with a concrete + owner and due date. + +## Platform claims + +The hosted matrix covers current macOS, Linux/glibc, exact Bash 4.2.53, and +representative Bash 4.x and 5.x releases. Alpine/musl runs in the networkless +release gate. No maintained GitHub-hosted BSD runner is currently available; +BSD remains explicitly advisory until a maintained runner can execute the same +contract. A release must not describe advisory BSD evidence as a supported +guarantee. diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 4fc51ad..d40710f 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -7,14 +7,14 @@ unreported pass. | Dimension | Supported contract | Evidence | | --- | --- | --- | -| Bash | 4.2.53 minimum; representative 4.x; current 5.x | Pinned networkless Bash 4.2 smoke, current runner validation, \`tests/compatibility-matrix.sh\`, deterministic property and artifact contracts | -| macOS | Current GitHub-hosted macOS with Homebrew Bash; system Bash 3.2 is rejected with remediation | macOS validation and unsupported-system-Bash smoke | -| Linux/glibc | Ubuntu runner and pinned Bash 4.2 container | Ubuntu validation and compatibility workflow | +| Bash | 4.2.53 minimum; representative 4.4.23 and 5.0.18/5.2.37; current 5.x | Pinned networkless Bash containers, current runner validation, \`tests/compatibility-matrix.sh\`, deterministic property, artifact, and concurrency contracts | +| macOS | Pinned GitHub-hosted macOS 14 with Homebrew Bash; system Bash 3.2 is rejected with remediation | macOS validation and unsupported-system-Bash smoke | +| Linux/glibc | Pinned Ubuntu 24.04 runner and Bash 4.2/4.4/5.0/5.2 containers | Ubuntu validation and compatibility workflow | | Linux/musl | Alpine/musl syntax and option-contract probe when the runner provides Docker | \`tests/compatibility-matrix.sh --container alpine\` | | BSD userland | Best-effort portability checks; no release guarantee until a maintained CI runner is available | Explicitly reported as advisory | | Locale | UTF-8 and \`C\` locale behavior for parsing, sorting, and diagnostics | Option and parser tests; caller owns locale selection | | Filesystem | Local POSIX filesystem; symlink and race checks are fail-closed | cleanup, import, bundle, vendor, marker, and artifact-contract tests | -| Network | Core tests are networkless; GitHub/Homebrew integrations are optional and bounded | workflow permissions, Docker \`--network none\`, retry tests | +| Network | Core tests are networkless; GitHub/Homebrew integrations are optional and bounded | read-only workflow permissions, Docker \`--network none\`, retry tests | ## Strict-option combinations @@ -39,6 +39,16 @@ argv quoting, empty and glob-like fields, repeatable options, marker edits, and command-like data. The seed is reported on failure so a downstream report can replay the exact case without network access or a package manager. +\`tests/concurrency-contract.sh\` runs sixteen independent import and cleanup +workers in parallel and verifies unique managed temporary directories. Signal, +process-tree, and launcher cleanup behavior is covered by the launcher BATS +suite; benchmark evidence is checked by \`tests/benchmark-contract.sh\`. + +Workflow formatting and action syntax are required in the separate Quality +workflow. Its shfmt and actionlint images are full-digest pinned and run with +network disabled, read-only mounts, dropped capabilities, and least-privilege +users. See [the CI policy](ci-policy.md) for the branch-protection contract. + ## Caller responsibilities Applications must select a supported Bash, avoid mutating framework-owned diff --git a/tests/concurrency-contract.sh b/tests/concurrency-contract.sh new file mode 100755 index 0000000..dbd75b3 --- /dev/null +++ b/tests/concurrency-contract.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +# Exercise import, temporary-directory, cleanup, and launcher boundaries in +# parallel processes. The contract is deliberately deterministic and +# networkless; it catches shared-state regressions without making a timing +# claim about a particular machine. + +concurrency_script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || exit 1 +concurrency_repo_root="$(cd -- "$concurrency_script_dir/.." && pwd -P)" || exit 1 +concurrency_tmp="$(mktemp -d "${TMPDIR:-/tmp}/base-bash-concurrency-contract.XXXXXX")" || exit 1 +concurrency_status=0 + +concurrency_cleanup() { + rm -rf -- "$concurrency_tmp" +} +trap concurrency_cleanup EXIT + +concurrency_fail() { + printf 'Concurrency contract failed: %s\n' "$*" >&2 + concurrency_status=1 +} + +concurrency_worker() { + local worker_id="$1" + local worker_dir output expected + trap - EXIT + # shellcheck disable=SC2034 # base_init publishes into this caller-owned array by name. + local -a init_args=() + + export BASE_BASH_LIBS_DIR="$concurrency_repo_root/lib/bash" + # shellcheck source=../lib/bash/std/lib_std.sh + source "$concurrency_repo_root/lib/bash/std/lib_std.sh" || return 1 + base_init init_args --source "$concurrency_script_dir/concurrency-contract.sh" -- || return 1 + base_std_make_temp_dir worker_dir "concurrency-$worker_id" || return 1 + base_std_import str/lib_str.sh || return 1 + output=" worker-$worker_id " + base_str_trim output || return 1 + expected="worker-$worker_id" + [[ "$output" == "$expected" ]] || return 1 + [[ -d "$worker_dir" ]] || return 1 + # Async function subshells do not consistently deliver inherited EXIT + # traps on every supported Bash release. Invoke the shared dispatcher + # explicitly so the cleanup assertion remains portable and deterministic. + __base_bash_libs_std_run_cleanup_hooks__ || return $? + [[ ! -e "$worker_dir" ]] || return 1 + printf '%s\n' "$worker_dir" +} + +concurrency_workers=() +concurrency_logs=() +concurrency_worker_count=16 +for ((concurrency_index = 1; concurrency_index <= concurrency_worker_count; concurrency_index += 1)); do + concurrency_log="$concurrency_tmp/worker-$concurrency_index.log" + concurrency_logs+=("$concurrency_log") + concurrency_worker "$concurrency_index" > "$concurrency_log" 2>&1 & + concurrency_workers+=("$!") +done + +for concurrency_index in "${!concurrency_workers[@]}"; do + if ! wait "${concurrency_workers[$concurrency_index]}"; then + concurrency_fail "worker $((concurrency_index + 1)) returned a failure" + sed 's/^/ /' "${concurrency_logs[$concurrency_index]}" >&2 + fi +done + +concurrency_seen="$concurrency_tmp/seen" +: > "$concurrency_seen" || concurrency_fail 'could not create result ledger' +for concurrency_log in "${concurrency_logs[@]}"; do + concurrency_worker_dir="$(tail -n 1 "$concurrency_log" 2> /dev/null || true)" + [[ -n "$concurrency_worker_dir" ]] || { + concurrency_fail "worker did not publish a managed directory: $concurrency_log" + continue + } + [[ ! -e "$concurrency_worker_dir" ]] || + concurrency_fail "worker directory was not cleaned up: $concurrency_worker_dir" + printf '%s\n' "$concurrency_worker_dir" >> "$concurrency_seen" +done + +concurrency_result_count="$(wc -l < "$concurrency_seen" | tr -d ' ')" +[[ "$concurrency_result_count" == "$concurrency_worker_count" ]] || + concurrency_fail "expected $concurrency_worker_count worker results, found $concurrency_result_count" + +concurrency_unique_count="$(sort -u "$concurrency_seen" | wc -l | tr -d ' ')" +[[ "$concurrency_unique_count" == "$concurrency_worker_count" ]] || + concurrency_fail 'parallel workers received duplicate temporary directories' + +if ((concurrency_status != 0)); then + exit "$concurrency_status" +fi + +printf 'Concurrency contract passed: workers=%s unique-temp-dirs=%s.\n' \ + "$concurrency_worker_count" "$concurrency_unique_count" diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 5854cfe..bee0f38 100755 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -4,30 +4,47 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 cd "$repo_root" || exit 1 required=( - docs/README.md - docs/v2/quickstart.md - docs/v2/architecture.md - docs/v2/migration-v1.4-to-v2.md - integrations.md + docs/README.md + docs/v2/quickstart.md + docs/v2/architecture.md + docs/v2/migration-v1.4-to-v2.md + docs/ci-policy.md + integrations.md ) for file in "${required[@]}"; do - [[ -f "$file" ]] || { printf 'Missing documentation contract file: %s\n' "$file" >&2; exit 1; } + [[ -f "$file" ]] || { + printf 'Missing documentation contract file: %s\n' "$file" >&2 + exit 1 + } done if grep -R -n -E 'checkout[[:space:]]+main|/archive/refs/heads/main|git clone .*[^[:alnum:]]main([[:space:]]|$)' \ - docs/v2 README.md; then - printf 'Adoption documentation must not install from an unreleased moving main branch.\n' >&2 - exit 1 + docs/v2 README.md; then + printf 'Adoption documentation must not install from an unreleased moving main branch.\n' >&2 + exit 1 fi -grep -F 'v2/quickstart.md' docs/README.md >/dev/null || { printf 'Documentation link is missing: v2/quickstart.md\n' >&2; exit 1; } -grep -F 'v2/architecture.md' docs/README.md >/dev/null || { printf 'Documentation link is missing: v2/architecture.md\n' >&2; exit 1; } -grep -F 'v2/migration-v1.4-to-v2.md' docs/README.md >/dev/null || { printf 'Documentation link is missing: v2/migration-v1.4-to-v2.md\n' >&2; exit 1; } -for link in SECURITY.md docs/support-policy.md docs/threat-model.md; do - grep -R -F "$link" docs/README.md README.md >/dev/null || { - printf 'Documentation link is missing: %s\n' "$link" >&2 +grep -F 'v2/quickstart.md' docs/README.md > /dev/null || { + printf 'Documentation link is missing: v2/quickstart.md\n' >&2 + exit 1 +} +grep -F 'v2/architecture.md' docs/README.md > /dev/null || { + printf 'Documentation link is missing: v2/architecture.md\n' >&2 exit 1 - } +} +grep -F 'v2/migration-v1.4-to-v2.md' docs/README.md > /dev/null || { + printf 'Documentation link is missing: v2/migration-v1.4-to-v2.md\n' >&2 + exit 1 +} +grep -F 'ci-policy.md' docs/README.md > /dev/null || { + printf 'Documentation link is missing: ci-policy.md\n' >&2 + exit 1 +} +for link in SECURITY.md docs/support-policy.md docs/threat-model.md; do + grep -R -F "$link" docs/README.md README.md > /dev/null || { + printf 'Documentation link is missing: %s\n' "$link" >&2 + exit 1 + } done printf 'Documentation contract passed.\n' diff --git a/tests/lint-warnings.sh b/tests/lint-warnings.sh index 544a3c4..8585a77 100755 --- a/tests/lint-warnings.sh +++ b/tests/lint-warnings.sh @@ -4,53 +4,56 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 cd "$repo_root" || exit 1 run_stage() { - local label="$1" - local status - shift + local label="$1" + local status + shift - if "$@"; then - return 0 - else - status=$? - fi + if "$@"; then + return 0 + else + status=$? + fi - printf 'Validation stage failed: %s (exit %s).\n' "$label" "$status" >&2 - return "$status" + printf 'Validation stage failed: %s (exit %s).\n' "$label" "$status" >&2 + return "$status" } lint_files=( - bin/base-bash - scripts/release - scripts/library-bundle - scripts/vendor - tests/fixtures/basectl-release-stub - tests/bash-42-release-smoke.sh - tests/bash-42-logging-smoke.sh - tests/bash-option-contract.sh - tests/validate.sh - tests/lint-warnings.sh - tests/artifact-contract.sh - tests/property-contract.sh - tests/benchmark-contract.sh - examples/std-usage.sh - examples/cookbook-cleanup-temp.sh - examples/cookbook-args-lists-strings.sh - lib/bash/std/lib_std.sh - lib/bash/file/lib_file.sh - lib/bash/git/lib_git.sh - lib/bash/gh/lib_gh.sh - lib/bash/str/lib_str.sh - lib/bash/arg/lib_arg.sh - lib/bash/list/lib_list.sh - lib/bash/cli/lib_cli.sh - lib/bash/app/lib_app.sh - lib/bash/tests/test_helper.sh - tests/consumer-kit/test_helper.bash - tests/consumer-kit/tests/consumer_kit.bats - tests/library-bundle.bats - tests/vendor.bats - tests/launcher.bats - tests/release.bats + bin/base-bash + scripts/release + scripts/library-bundle + scripts/vendor + tests/fixtures/basectl-release-stub + tests/bash-42-release-smoke.sh + tests/bash-42-logging-smoke.sh + tests/bash-option-contract.sh + tests/validate.sh + tests/lint-warnings.sh + tests/artifact-contract.sh + tests/property-contract.sh + tests/benchmark-contract.sh + tests/concurrency-contract.sh + tests/quality-contract.sh + tests/shfmt-contract.sh + examples/std-usage.sh + examples/cookbook-cleanup-temp.sh + examples/cookbook-args-lists-strings.sh + lib/bash/std/lib_std.sh + lib/bash/file/lib_file.sh + lib/bash/git/lib_git.sh + lib/bash/gh/lib_gh.sh + lib/bash/str/lib_str.sh + lib/bash/arg/lib_arg.sh + lib/bash/list/lib_list.sh + lib/bash/cli/lib_cli.sh + lib/bash/app/lib_app.sh + lib/bash/tests/test_helper.sh + tests/consumer-kit/test_helper.bash + tests/consumer-kit/tests/consumer_kit.bats + tests/library-bundle.bats + tests/vendor.bats + tests/launcher.bats + tests/release.bats ) run_stage "ShellCheck warning profile" shellcheck --severity=warning "${lint_files[@]}" || exit $? diff --git a/tests/quality-contract.sh b/tests/quality-contract.sh new file mode 100755 index 0000000..917170c --- /dev/null +++ b/tests/quality-contract.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Repository-local assertions for the hosted quality workflow. Tool execution +# happens in the pinned CI containers; this contract prevents the workflow +# from silently losing its required checks, isolation, or immutable inputs. + +quality_repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 +cd "$quality_repo_root" || exit 1 + +quality_fail() { + printf 'Quality contract failed: %s\n' "$*" >&2 + exit 1 +} + +quality_workflow=".github/workflows/quality.yml" +[[ -f "$quality_workflow" ]] || quality_fail "missing $quality_workflow" + +grep -F 'permissions:' "$quality_workflow" > /dev/null || quality_fail 'quality workflow must declare permissions' +grep -F 'contents: read' "$quality_workflow" > /dev/null || quality_fail 'quality workflow must be read-only' +grep -F 'mvdan/shfmt@sha256:' "$quality_workflow" > /dev/null || quality_fail 'shfmt image must be digest-pinned' +grep -F 'rhysd/actionlint@sha256:' "$quality_workflow" > /dev/null || quality_fail 'actionlint image must be digest-pinned' +grep -F -- '--network none' "$quality_workflow" > /dev/null || quality_fail 'quality containers must be networkless' +grep -F -- '--read-only' "$quality_workflow" > /dev/null || quality_fail 'quality containers must be read-only' +grep -F 'tests/lint-warnings.sh' "$quality_workflow" > /dev/null || quality_fail 'warning ShellCheck gate is missing' +grep -F -- '-d -ln bash' "$quality_workflow" > /dev/null || quality_fail 'shfmt contract gate is missing' +grep -F 'tests/quality-contract.sh' "$quality_workflow" > /dev/null || quality_fail 'quality contract gate is missing' +grep -F 'git diff --name-only' "$quality_workflow" > /dev/null || quality_fail 'shfmt gate must inspect changed sources' + +workflow_action_count=0 +while IFS= read -r action_ref; do + [[ -n "$action_ref" ]] || continue + [[ "$action_ref" =~ @[0-9a-f]{40}([[:space:]]|$) ]] || + quality_fail "workflow action is not pinned: $action_ref" + workflow_action_count=$((workflow_action_count + 1)) +done < <(grep -E '^[[:space:]]*-[[:space:]]*uses:[[:space:]]*[^#]+' .github/workflows/*.yml || true) +((workflow_action_count > 0)) || quality_fail 'no pinned workflow actions were found' + +grep -F 'concurrency:' .github/workflows/tests.yml > /dev/null || + quality_fail 'tests workflow must cancel superseded runs' +grep -F 'concurrency:' .github/workflows/quality.yml > /dev/null || + quality_fail 'quality workflow must cancel superseded runs' + +printf 'Quality contract passed: pinned-actions=%s.\n' "$workflow_action_count" diff --git a/tests/shfmt-contract.sh b/tests/shfmt-contract.sh new file mode 100755 index 0000000..202a2fa --- /dev/null +++ b/tests/shfmt-contract.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Formatting is a required CI check, but shfmt remains a CI tool rather than +# a runtime dependency. The workflow supplies the pinned shfmt container; +# local callers can install shfmt and run this same contract. + +shfmt_repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 +cd "$shfmt_repo_root" || exit 1 + +command -v shfmt > /dev/null 2>&1 || { + printf 'shfmt is required for this contract; install it or use the pinned CI image.\n' >&2 + exit 127 +} + +shfmt_files=() +while IFS= read -r shfmt_file; do + case "$shfmt_file" in + *.sh | *.bash | bin/base-bash | scripts/api-manifest | scripts/first-party-cutover | scripts/library-bundle | scripts/migrate-v2-symbols | scripts/release | scripts/vendor | tests/fixtures/basectl-release-stub) + shfmt_files+=("$shfmt_file") + ;; + esac +done < <(git ls-files) + +((${#shfmt_files[@]} > 0)) || { + printf 'shfmt contract failed: no Bash sources were found.\n' >&2 + exit 1 +} + +shfmt -d -ln bash -i 4 -sr "${shfmt_files[@]}" diff --git a/tests/validate.sh b/tests/validate.sh index d7598b5..173dca6 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -3,116 +3,48 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 required_files=( - README.md - VERSION - CHANGELOG.md - CONTRIBUTING.md - .github/pull_request_template.md - .github/base-project.yml - LICENSE - NOTICE - SECURITY.md - base_manifest.yaml - base_api_manifest.yaml - docs/versioning-policy.md - docs/README.md - docs/v2/quickstart.md - docs/v2/architecture.md - docs/v2/migration-v1.4-to-v2.md - integrations.md - docs/support-policy.md - docs/threat-model.md - docs/v2-api-contract.md - docs/v2-symbol-map.md - docs/api-reference.md - docs/api-manifest-schema.md - docs/support-matrix.md - docs/integrations.md - integrations/bashly/base_bashly.sh - integrations/bashly/example.sh - integrations/bats/base_bats_helper.bash - integrations/project-kit/.shellcheckrc - integrations/project-kit/.editorconfig - integrations/project-kit/shfmt.conf - integrations/package-managers/registry.yaml - .github/workflows/project-intake.yml - .github/ISSUE_TEMPLATE/config.yml - .github/workflows/tests.yml - bin/base-bash - scripts/release - scripts/api-manifest - scripts/library-bundle - scripts/vendor - scripts/migrate-v2-symbols - tests/fixtures/basectl-release-stub - tests/bash-42-release-smoke.sh - tests/bash-42-logging-smoke.sh - tests/bash-option-contract.sh - tests/compatibility-matrix.sh - tests/artifact-contract.sh - tests/property-contract.sh - tests/benchmark-contract.sh - tests/release-invariants.sh - examples/std-usage.sh - examples/cookbook-cleanup-temp.sh - examples/cookbook-args-lists-strings.sh - tests/release.bats - tests/namespace-contract.bats - tests/api-manifest.bats - tests/consumer-kit/tests/consumer_kit.bats - tests/library-bundle.bats - tests/vendor.bats - tests/lint-warnings.sh - tests/docs-contract.sh - tests/integrations.bats - examples/reference-apps/README.md - examples/reference-apps/installer/lib/app.sh - examples/reference-apps/installer/tests/app.bats - examples/reference-apps/release-helper/lib/app.sh - examples/reference-apps/release-helper/tests/app.bats - examples/reference-apps/ops-cli/lib/app.sh - examples/reference-apps/ops-cli/tests/app.bats - examples/reference-apps/verify.sh - benchmarks/README.md - benchmarks/reference-apps.sh - tests/reference-apps.bats - CODE_OF_CONDUCT.md - ROADMAP.md - docs/community.md - docs/who-uses-base-bash.md - docs/independent-validation.md - .github/ISSUE_TEMPLATE/bug.yml - .github/ISSUE_TEMPLATE/feature.yml - .github/ISSUE_TEMPLATE/documentation.yml - tests/community-contract.sh - first-party-cutover.yaml - docs/first-party-cutover.md - scripts/first-party-cutover - tests/first-party-cutover.bats -) - -cd "$repo_root" || exit 1 - -run_stage() { - local label="$1" - local status - shift - - if "$@"; then - return 0 - else - status=$? - fi - - printf 'Validation stage failed: %s (exit %s).\n' "$label" "$status" >&2 - return "$status" -} - -check_no_strict_mode() { - local file matches status - local strict_mode_files=( + README.md + VERSION + CHANGELOG.md + CONTRIBUTING.md + .github/pull_request_template.md + .github/base-project.yml + LICENSE + NOTICE + SECURITY.md + base_manifest.yaml + base_api_manifest.yaml + docs/versioning-policy.md + docs/README.md + docs/v2/quickstart.md + docs/v2/architecture.md + docs/v2/migration-v1.4-to-v2.md + integrations.md + docs/support-policy.md + docs/threat-model.md + docs/v2-api-contract.md + docs/v2-symbol-map.md + docs/api-reference.md + docs/api-manifest-schema.md + docs/support-matrix.md + docs/ci-policy.md + docs/integrations.md + integrations/bashly/base_bashly.sh + integrations/bashly/example.sh + integrations/bats/base_bats_helper.bash + integrations/project-kit/.shellcheckrc + integrations/project-kit/.editorconfig + integrations/project-kit/shfmt.conf + integrations/package-managers/registry.yaml + .github/workflows/project-intake.yml + .github/ISSUE_TEMPLATE/config.yml + .github/workflows/tests.yml bin/base-bash scripts/release + scripts/api-manifest + scripts/library-bundle + scripts/vendor + scripts/migrate-v2-symbols tests/fixtures/basectl-release-stub tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh @@ -121,56 +53,131 @@ check_no_strict_mode() { tests/artifact-contract.sh tests/property-contract.sh tests/benchmark-contract.sh + tests/concurrency-contract.sh + tests/quality-contract.sh + tests/shfmt-contract.sh tests/release-invariants.sh - tests/validate.sh + examples/std-usage.sh + examples/cookbook-cleanup-temp.sh + examples/cookbook-args-lists-strings.sh + tests/release.bats + tests/namespace-contract.bats + tests/api-manifest.bats + tests/consumer-kit/tests/consumer_kit.bats + tests/library-bundle.bats + tests/vendor.bats tests/lint-warnings.sh - scripts/migrate-v2-symbols - examples/*.sh - lib/bash/*/lib_*.sh - ) - - for file in "${strict_mode_files[@]}"; do - if matches="$(grep -n -E \ - '^[[:space:]]*set[[:space:]]+-[^[:space:]]*[eu]([[:space:]]|$)|^[[:space:]]*set[[:space:]]+-o[[:space:]]+(errexit|nounset|pipefail)([[:space:]]|$)|^[[:space:]]*set[[:space:]]+.*[[:space:]]pipefail([[:space:]]|$)' \ - "$file")"; then - printf 'Strict mode is not allowed in production or validation shell file %s:\n%s\n' \ - "$file" "$matches" >&2 - return 1 + tests/docs-contract.sh + tests/integrations.bats + examples/reference-apps/README.md + examples/reference-apps/installer/lib/app.sh + examples/reference-apps/installer/tests/app.bats + examples/reference-apps/release-helper/lib/app.sh + examples/reference-apps/release-helper/tests/app.bats + examples/reference-apps/ops-cli/lib/app.sh + examples/reference-apps/ops-cli/tests/app.bats + examples/reference-apps/verify.sh + benchmarks/README.md + benchmarks/reference-apps.sh + tests/reference-apps.bats + CODE_OF_CONDUCT.md + ROADMAP.md + docs/community.md + docs/who-uses-base-bash.md + docs/independent-validation.md + .github/ISSUE_TEMPLATE/bug.yml + .github/ISSUE_TEMPLATE/feature.yml + .github/ISSUE_TEMPLATE/documentation.yml + tests/community-contract.sh + first-party-cutover.yaml + docs/first-party-cutover.md + scripts/first-party-cutover + tests/first-party-cutover.bats +) + +cd "$repo_root" || exit 1 + +run_stage() { + local label="$1" + local status + shift + + if "$@"; then + return 0 else - status=$? - if ((status != 1)); then - printf 'Unable to inspect %s for strict mode (exit %s).\n' "$file" "$status" >&2 - return "$status" - fi + status=$? fi - done + + printf 'Validation stage failed: %s (exit %s).\n' "$label" "$status" >&2 + return "$status" +} + +check_no_strict_mode() { + local file matches status + local strict_mode_files=( + bin/base-bash + scripts/release + tests/fixtures/basectl-release-stub + tests/bash-42-release-smoke.sh + tests/bash-42-logging-smoke.sh + tests/bash-option-contract.sh + tests/compatibility-matrix.sh + tests/artifact-contract.sh + tests/property-contract.sh + tests/benchmark-contract.sh + tests/concurrency-contract.sh + tests/quality-contract.sh + tests/shfmt-contract.sh + tests/release-invariants.sh + tests/validate.sh + tests/lint-warnings.sh + scripts/migrate-v2-symbols + examples/*.sh + lib/bash/*/lib_*.sh + ) + + for file in "${strict_mode_files[@]}"; do + if matches="$(grep -n -E \ + '^[[:space:]]*set[[:space:]]+-[^[:space:]]*[eu]([[:space:]]|$)|^[[:space:]]*set[[:space:]]+-o[[:space:]]+(errexit|nounset|pipefail)([[:space:]]|$)|^[[:space:]]*set[[:space:]]+.*[[:space:]]pipefail([[:space:]]|$)' \ + "$file")"; then + printf 'Strict mode is not allowed in production or validation shell file %s:\n%s\n' \ + "$file" "$matches" >&2 + return 1 + else + status=$? + if ((status != 1)); then + printf 'Unable to inspect %s for strict mode (exit %s).\n' "$file" "$status" >&2 + return "$status" + fi + fi + done } for file in "${required_files[@]}"; do - [[ -f "$file" ]] || { - printf 'Missing required file: %s\n' "$file" >&2 - exit 1 - } + [[ -f "$file" ]] || { + printf 'Missing required file: %s\n' "$file" >&2 + exit 1 + } done manifest_artifacts="$(scripts/api-manifest artifact-paths)" || exit $? while IFS= read -r file; do - [[ -n "$file" ]] && required_files+=("$file") -done <<<"$manifest_artifacts" + [[ -n "$file" ]] && required_files+=("$file") +done <<< "$manifest_artifacts" manifest_module_paths="$(scripts/api-manifest module-paths)" || exit $? while IFS=$'\t' read -r module_kind readme test_path; do - [[ -n "$module_kind" && -n "$readme" && -n "$test_path" ]] || continue - [[ -f "$test_path" ]] || { - printf 'Documented BATS path does not exist: %s\n' "$test_path" >&2 - exit 1 - } - [[ "$module_kind" != sourceable-library ]] && continue - grep -F "$test_path" "$readme" >/dev/null || { - printf 'README does not document its BATS path: %s -> %s\n' "$readme" "$test_path" >&2 - exit 1 - } -done <<<"$manifest_module_paths" + [[ -n "$module_kind" && -n "$readme" && -n "$test_path" ]] || continue + [[ -f "$test_path" ]] || { + printf 'Documented BATS path does not exist: %s\n' "$test_path" >&2 + exit 1 + } + [[ "$module_kind" != sourceable-library ]] && continue + grep -F "$test_path" "$readme" > /dev/null || { + printf 'README does not document its BATS path: %s -> %s\n' "$readme" "$test_path" >&2 + exit 1 + } +done <<< "$manifest_module_paths" printf 'Repository baseline is present.\n' @@ -180,70 +187,70 @@ run_stage "documentation contract" tests/docs-contract.sh || exit $? version="" IFS= read -r version < VERSION || { - printf 'Unable to read VERSION.\n' >&2 - exit 1 + printf 'Unable to read VERSION.\n' >&2 + exit 1 } readme_head="$(sed -n '1,16p' README.md | tr -d '\r')" -if ! printf '%s\n' "$readme_head" | grep -F "[![Tests](https://img.shields.io/github/actions/workflow/status/basefoundry/base-bash-libs/tests.yml?branch=main&label=tests)](https://github.com/basefoundry/base-bash-libs/actions/workflows/tests.yml)" >/dev/null; then - printf 'README.md is missing the main-branch tests health badge.\n' >&2 - exit 1 +if ! printf '%s\n' "$readme_head" | grep -F "[![Tests](https://img.shields.io/github/actions/workflow/status/basefoundry/base-bash-libs/tests.yml?branch=main&label=tests)](https://github.com/basefoundry/base-bash-libs/actions/workflows/tests.yml)" > /dev/null; then + printf 'README.md is missing the main-branch tests health badge.\n' >&2 + exit 1 fi -if ! printf '%s\n' "$readme_head" | grep -F "[![Release](https://img.shields.io/github/v/release/basefoundry/base-bash-libs?sort=semver&label=release)](https://github.com/basefoundry/base-bash-libs/releases)" >/dev/null; then - printf 'README.md is missing the GitHub release badge.\n' >&2 - exit 1 +if ! printf '%s\n' "$readme_head" | grep -F "[![Release](https://img.shields.io/github/v/release/basefoundry/base-bash-libs?sort=semver&label=release)](https://github.com/basefoundry/base-bash-libs/releases)" > /dev/null; then + printf 'README.md is missing the GitHub release badge.\n' >&2 + exit 1 fi -if ! printf '%s\n' "$readme_head" | grep -F "[![Bash](https://img.shields.io/badge/Bash-4.2%2B-4EAA25?logo=gnubash&logoColor=white)](docs/support-matrix.md)" >/dev/null; then - printf 'README.md is missing the supported Bash version badge.\n' >&2 - exit 1 +if ! printf '%s\n' "$readme_head" | grep -F "[![Bash](https://img.shields.io/badge/Bash-4.2%2B-4EAA25?logo=gnubash&logoColor=white)](docs/support-matrix.md)" > /dev/null; then + printf 'README.md is missing the supported Bash version badge.\n' >&2 + exit 1 fi if [[ ! "$version" =~ ^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$ ]]; then - printf 'VERSION is not a SemVer-compatible version: %s\n' "$version" >&2 - exit 1 + printf 'VERSION is not a SemVer-compatible version: %s\n' "$version" >&2 + exit 1 fi version_core="${version%%[-+]*}" latest_tag_core="0.0.0" while IFS= read -r tag; do - tag_version="${tag#v}" - tag_core="${tag_version%%[-+]*}" - [[ "$tag_core" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]] || continue - IFS=. read -r tag_major tag_minor tag_patch <<<"$tag_core" - IFS=. read -r latest_major latest_minor latest_patch <<<"$latest_tag_core" - if ((tag_major > latest_major)) || - ((tag_major == latest_major && tag_minor > latest_minor)) || - ((tag_major == latest_major && tag_minor == latest_minor && tag_patch > latest_patch)); then - latest_tag_core="$tag_core" - fi -done < <(git tag --list 'v[0-9]*' 2>/dev/null) -IFS=. read -r version_major version_minor version_patch <<<"$version_core" -IFS=. read -r latest_major latest_minor latest_patch <<<"$latest_tag_core" + tag_version="${tag#v}" + tag_core="${tag_version%%[-+]*}" + [[ "$tag_core" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]] || continue + IFS=. read -r tag_major tag_minor tag_patch <<< "$tag_core" + IFS=. read -r latest_major latest_minor latest_patch <<< "$latest_tag_core" + if ((tag_major > latest_major)) || + ((tag_major == latest_major && tag_minor > latest_minor)) || + ((tag_major == latest_major && tag_minor == latest_minor && tag_patch > latest_patch)); then + latest_tag_core="$tag_core" + fi +done < <(git tag --list 'v[0-9]*' 2> /dev/null) +IFS=. read -r version_major version_minor version_patch <<< "$version_core" +IFS=. read -r latest_major latest_minor latest_patch <<< "$latest_tag_core" if ((version_major < latest_major)) || - ((version_major == latest_major && version_minor < latest_minor)) || - ((version_major == latest_major && version_minor == latest_minor && version_patch < latest_patch)); then - printf 'VERSION %s moves behind the highest existing release tag v%s.\n' \ - "$version" "$latest_tag_core" >&2 - exit 1 + ((version_major == latest_major && version_minor < latest_minor)) || + ((version_major == latest_major && version_minor == latest_minor && version_patch < latest_patch)); then + printf 'VERSION %s moves behind the highest existing release tag v%s.\n' \ + "$version" "$latest_tag_core" >&2 + exit 1 fi release_metadata_version="$(sed -n 's/^version=//p' lib/bash/base-bash-libs.release | sed -n '1p')" if [[ "$release_metadata_version" != "$version" ]]; then - printf 'Embedded release metadata version (%s) does not match VERSION (%s).\n' \ - "$release_metadata_version" "$version" >&2 - exit 1 + printf 'Embedded release metadata version (%s) does not match VERSION (%s).\n' \ + "$release_metadata_version" "$version" >&2 + exit 1 fi for metadata_key in schema_version version commit dirty_state provenance; do - metadata_count="$(grep -c -E "^${metadata_key}=" lib/bash/base-bash-libs.release || true)" - [[ "$metadata_count" == 1 ]] || { - printf 'Embedded release metadata must contain exactly one %s key (found %s).\n' \ - "$metadata_key" "$metadata_count" >&2 - exit 1 - } - grep -E "^${metadata_key}=.+$" lib/bash/base-bash-libs.release >/dev/null || { - printf 'Embedded release metadata is missing key: %s\n' "$metadata_key" >&2 - exit 1 - } + metadata_count="$(grep -c -E "^${metadata_key}=" lib/bash/base-bash-libs.release || true)" + [[ "$metadata_count" == 1 ]] || { + printf 'Embedded release metadata must contain exactly one %s key (found %s).\n' \ + "$metadata_key" "$metadata_count" >&2 + exit 1 + } + grep -E "^${metadata_key}=.+$" lib/bash/base-bash-libs.release > /dev/null || { + printf 'Embedded release metadata is missing key: %s\n' "$metadata_key" >&2 + exit 1 + } done release_metadata_schema="$(sed -n 's/^schema_version=//p' lib/bash/base-bash-libs.release)" @@ -251,172 +258,175 @@ release_metadata_commit="$(sed -n 's/^commit=//p' lib/bash/base-bash-libs.releas release_metadata_dirty="$(sed -n 's/^dirty_state=//p' lib/bash/base-bash-libs.release)" release_metadata_provenance="$(sed -n 's/^provenance=//p' lib/bash/base-bash-libs.release)" [[ "$release_metadata_schema" == 1 ]] || { - printf 'Embedded release metadata schema_version must be 1.\n' >&2 - exit 1 + printf 'Embedded release metadata schema_version must be 1.\n' >&2 + exit 1 } [[ "$release_metadata_commit" == unknown || "$release_metadata_commit" =~ ^[[:xdigit:]]{40}$ ]] || { - printf 'Embedded release metadata commit must be a full SHA or unknown.\n' >&2 - exit 1 + printf 'Embedded release metadata commit must be a full SHA or unknown.\n' >&2 + exit 1 } [[ "$release_metadata_dirty" =~ ^(clean|dirty|unknown)$ ]] || { - printf 'Embedded release metadata dirty_state must be clean, dirty, or unknown.\n' >&2 - exit 1 + printf 'Embedded release metadata dirty_state must be clean, dirty, or unknown.\n' >&2 + exit 1 } [[ "$release_metadata_provenance" =~ ^(checkout|release-artifact|source-archive|copy|unknown)$ ]] || { - printf 'Embedded release metadata provenance is not recognized: %s\n' \ - "$release_metadata_provenance" >&2 - exit 1 + printf 'Embedded release metadata provenance is not recognized: %s\n' \ + "$release_metadata_provenance" >&2 + exit 1 } release_metadata_unknown_keys="$(grep -v -E '^(schema_version|version|commit|dirty_state|provenance)=' \ - lib/bash/base-bash-libs.release || true)" + lib/bash/base-bash-libs.release || true)" if [[ -n "$release_metadata_unknown_keys" ]]; then - printf 'Embedded release metadata contains unknown keys:\n%s\n' \ - "$release_metadata_unknown_keys" >&2 - exit 1 + printf 'Embedded release metadata contains unknown keys:\n%s\n' \ + "$release_metadata_unknown_keys" >&2 + exit 1 fi if [[ "$(grep -c '^## \[Unreleased\]' CHANGELOG.md)" != 1 ]]; then - printf 'CHANGELOG.md must contain exactly one [Unreleased] section.\n' >&2 - exit 1 + printf 'CHANGELOG.md must contain exactly one [Unreleased] section.\n' >&2 + exit 1 fi if [[ "$(grep -c -F "## [$version]" CHANGELOG.md)" -gt 1 ]]; then - printf 'CHANGELOG.md contains duplicate release sections for %s.\n' "$version" >&2 - exit 1 + printf 'CHANGELOG.md contains duplicate release sections for %s.\n' "$version" >&2 + exit 1 fi -if ! grep -F "| \`$version\` | [Apache-2.0](LICENSE) |" README.md >/dev/null; then - printf 'README.md top strip does not match VERSION (%s) and Apache-2.0 license metadata.\n' "$version" >&2 - exit 1 +if ! grep -F "| \`$version\` | [Apache-2.0](LICENSE) |" README.md > /dev/null; then + printf 'README.md top strip does not match VERSION (%s) and Apache-2.0 license metadata.\n' "$version" >&2 + exit 1 fi -if ! sed -n '1,30p' README.md | grep -F 'Requires Bash 4.2+' >/dev/null; then - printf 'README.md must state the Bash 4.2+ requirement near the top-level entry point.\n' >&2 - exit 1 +if ! sed -n '1,30p' README.md | grep -F 'Requires Bash 4.2+' > /dev/null; then + printf 'README.md must state the Bash 4.2+ requirement near the top-level entry point.\n' >&2 + exit 1 fi stale_base_refs="$(grep -R -n -E 'codeforester/base|github.com/codeforester' README.md lib/bash/README.md || true)" if [[ -n "$stale_base_refs" ]]; then - printf 'README files must not use stale codeforester Base coordinates:\n%s\n' "$stale_base_refs" >&2 - exit 1 + printf 'README files must not use stale codeforester Base coordinates:\n%s\n' "$stale_base_refs" >&2 + exit 1 fi -if ! grep -F ' - main' .github/workflows/tests.yml >/dev/null; then - printf 'Tests workflow must run push validation on the main branch.\n' >&2 - exit 1 +if ! grep -F ' - main' .github/workflows/tests.yml > /dev/null; then + printf 'Tests workflow must run push validation on the main branch.\n' >&2 + exit 1 fi -if grep -F 'secrets.BASE_PROJECT_TOKEN || github.token' .github/workflows/project-intake.yml >/dev/null; then - printf 'Project intake workflow must not fall back to github.token for org Project writes.\n' >&2 - exit 1 +if grep -F 'secrets.BASE_PROJECT_TOKEN || github.token' .github/workflows/project-intake.yml > /dev/null; then + printf 'Project intake workflow must not fall back to github.token for org Project writes.\n' >&2 + exit 1 fi -if ! grep -F 'GH_TOKEN: ${{ secrets.BASE_PROJECT_TOKEN }}' .github/workflows/project-intake.yml >/dev/null; then - printf 'Project intake workflow must use BASE_PROJECT_TOKEN directly for gh commands.\n' >&2 - exit 1 +if ! grep -F 'GH_TOKEN: ${{ secrets.BASE_PROJECT_TOKEN }}' .github/workflows/project-intake.yml > /dev/null; then + printf 'Project intake workflow must use BASE_PROJECT_TOKEN directly for gh commands.\n' >&2 + exit 1 fi -if ! grep -F 'BASE_PROJECT_MIN_GRAPHQL_REMAINING' .github/workflows/project-intake.yml >/dev/null; then - printf 'Project intake workflow must define a minimum GraphQL quota before Project mutations.\n' >&2 - exit 1 +if ! grep -F 'BASE_PROJECT_MIN_GRAPHQL_REMAINING' .github/workflows/project-intake.yml > /dev/null; then + printf 'Project intake workflow must define a minimum GraphQL quota before Project mutations.\n' >&2 + exit 1 fi -if ! grep -F 'rateLimit { remaining resetAt }' .github/workflows/project-intake.yml >/dev/null; then - printf 'Project intake workflow must check GitHub GraphQL quota before Project mutations.\n' >&2 - exit 1 +if ! grep -F 'rateLimit { remaining resetAt }' .github/workflows/project-intake.yml > /dev/null; then + printf 'Project intake workflow must check GitHub GraphQL quota before Project mutations.\n' >&2 + exit 1 fi -if ! grep -F 'Project intake backfill' CONTRIBUTING.md >/dev/null; then - printf 'CONTRIBUTING.md must document the throttled Project intake backfill workflow.\n' >&2 - exit 1 +if ! grep -F 'Project intake backfill' CONTRIBUTING.md > /dev/null; then + printf 'CONTRIBUTING.md must document the throttled Project intake backfill workflow.\n' >&2 + exit 1 fi -if ! grep -F 'gh workflow run project-intake.yml' CONTRIBUTING.md >/dev/null; then - printf 'CONTRIBUTING.md must include the manual Project intake workflow dispatch command.\n' >&2 - exit 1 +if ! grep -F 'gh workflow run project-intake.yml' CONTRIBUTING.md > /dev/null; then + printf 'CONTRIBUTING.md must include the manual Project intake workflow dispatch command.\n' >&2 + exit 1 fi fix_comments="$(grep -R -n '# FIX:' lib/bash || true)" if [[ -n "$fix_comments" ]]; then - printf 'Production library files must not contain development # FIX: comments:\n%s\n' "$fix_comments" >&2 - exit 1 + printf 'Production library files must not contain development # FIX: comments:\n%s\n' "$fix_comments" >&2 + exit 1 fi for command in shellcheck bats; do - command -v "$command" >/dev/null 2>&1 || { - printf "Required validation command '%s' was not found.\n" "$command" >&2 - exit 1 - } + command -v "$command" > /dev/null 2>&1 || { + printf "Required validation command '%s' was not found.\n" "$command" >&2 + exit 1 + } done manifest_source_paths="$(scripts/api-manifest source-paths)" || exit $? manifest_shellcheck_paths=() while IFS= read -r file; do - [[ -n "$file" ]] && manifest_shellcheck_paths+=("$file") -done <<<"$manifest_source_paths" + [[ -n "$file" ]] && manifest_shellcheck_paths+=("$file") +done <<< "$manifest_source_paths" run_stage "ShellCheck error profile" shellcheck --severity=error \ - bin/base-bash \ - scripts/api-manifest \ - scripts/library-bundle \ - scripts/vendor \ - scripts/release \ - scripts/migrate-v2-symbols \ - tests/fixtures/basectl-release-stub \ - tests/bash-42-release-smoke.sh \ - tests/bash-42-logging-smoke.sh \ - tests/bash-option-contract.sh \ - tests/compatibility-matrix.sh \ - tests/artifact-contract.sh \ - tests/property-contract.sh \ - tests/benchmark-contract.sh \ - tests/release-invariants.sh \ - tests/validate.sh \ - tests/lint-warnings.sh \ - examples/std-usage.sh \ - examples/cookbook-cleanup-temp.sh \ - examples/cookbook-args-lists-strings.sh \ - lib/bash/tests/test_helper.sh \ - "${manifest_shellcheck_paths[@]}" \ - tests/release.bats \ - tests/namespace-contract.bats \ - tests/api-manifest.bats \ - tests/consumer-kit/test_helper.bash \ - tests/consumer-kit/tests/consumer_kit.bats \ - tests/library-bundle.bats \ - tests/vendor.bats \ - integrations/bashly/base_bashly.sh \ - integrations/bashly/example.sh \ - integrations/bats/base_bats_helper.bash \ - tests/integrations.bats \ - examples/reference-apps/installer/lib/app.sh \ - examples/reference-apps/release-helper/lib/app.sh \ - examples/reference-apps/ops-cli/lib/app.sh \ - examples/reference-apps/verify.sh \ - benchmarks/reference-apps.sh \ - tests/reference-apps.bats \ - tests/community-contract.sh \ - scripts/first-party-cutover \ - tests/first-party-cutover.bats + bin/base-bash \ + scripts/api-manifest \ + scripts/library-bundle \ + scripts/vendor \ + scripts/release \ + scripts/migrate-v2-symbols \ + tests/fixtures/basectl-release-stub \ + tests/bash-42-release-smoke.sh \ + tests/bash-42-logging-smoke.sh \ + tests/bash-option-contract.sh \ + tests/compatibility-matrix.sh \ + tests/artifact-contract.sh \ + tests/property-contract.sh \ + tests/benchmark-contract.sh \ + tests/concurrency-contract.sh \ + tests/quality-contract.sh \ + tests/shfmt-contract.sh \ + tests/release-invariants.sh \ + tests/validate.sh \ + tests/lint-warnings.sh \ + examples/std-usage.sh \ + examples/cookbook-cleanup-temp.sh \ + examples/cookbook-args-lists-strings.sh \ + lib/bash/tests/test_helper.sh \ + "${manifest_shellcheck_paths[@]}" \ + tests/release.bats \ + tests/namespace-contract.bats \ + tests/api-manifest.bats \ + tests/consumer-kit/test_helper.bash \ + tests/consumer-kit/tests/consumer_kit.bats \ + tests/library-bundle.bats \ + tests/vendor.bats \ + integrations/bashly/base_bashly.sh \ + integrations/bashly/example.sh \ + integrations/bats/base_bats_helper.bash \ + tests/integrations.bats \ + examples/reference-apps/installer/lib/app.sh \ + examples/reference-apps/release-helper/lib/app.sh \ + examples/reference-apps/ops-cli/lib/app.sh \ + examples/reference-apps/verify.sh \ + benchmarks/reference-apps.sh \ + tests/reference-apps.bats \ + tests/community-contract.sh \ + scripts/first-party-cutover \ + tests/first-party-cutover.bats bats_files=( - tests/release.bats - tests/namespace-contract.bats - tests/api-manifest.bats - tests/consumer-kit/tests/consumer_kit.bats - tests/library-bundle.bats \ - tests/vendor.bats \ - tests/integrations.bats \ - tests/reference-apps.bats \ - tests/first-party-cutover.bats + tests/release.bats + tests/namespace-contract.bats + tests/api-manifest.bats + tests/consumer-kit/tests/consumer_kit.bats + tests/library-bundle.bats + tests/vendor.bats + tests/integrations.bats + tests/reference-apps.bats + tests/first-party-cutover.bats ) manifest_test_paths="$(scripts/api-manifest test-paths)" || exit $? while IFS= read -r file; do - [[ -n "$file" ]] && bats_files+=("$file") -done <<<"$manifest_test_paths" + [[ -n "$file" ]] && bats_files+=("$file") +done <<< "$manifest_test_paths" run_stage "BATS test suites" bats \ - "${bats_files[@]}" || exit $? + "${bats_files[@]}" || exit $? run_stage "Bash logging smoke" tests/bash-42-logging-smoke.sh || exit $? run_stage "Bash release guard smoke" tests/bash-42-release-smoke.sh || exit $? @@ -424,14 +434,16 @@ run_stage "Bash caller-option contract" tests/bash-option-contract.sh || exit $? run_stage "deterministic property contract" tests/property-contract.sh || exit $? run_stage "distribution artifact contract" tests/artifact-contract.sh || exit $? run_stage "benchmark contract" tests/benchmark-contract.sh || exit $? +run_stage "concurrency contract" tests/concurrency-contract.sh || exit $? +run_stage "quality workflow contract" tests/quality-contract.sh || exit $? run_stage "support matrix" tests/compatibility-matrix.sh || exit $? run_stage "release invariants" tests/release-invariants.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 $? -run_stage "Bashly integration example" integrations/bashly/example.sh candidate >/dev/null || exit $? -run_stage "reference application smoke" examples/reference-apps/verify.sh >/dev/null || exit $? -run_stage "community contract" tests/community-contract.sh >/dev/null || exit $? -run_stage "first-party cutover pending check" scripts/first-party-cutover check --allow-pending >/dev/null || 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 $? +run_stage "Bashly integration example" integrations/bashly/example.sh candidate > /dev/null || exit $? +run_stage "reference application smoke" examples/reference-apps/verify.sh > /dev/null || exit $? +run_stage "community contract" tests/community-contract.sh > /dev/null || exit $? +run_stage "first-party cutover pending check" scripts/first-party-cutover check --allow-pending > /dev/null || exit $? printf 'Bash library validation passed.\n'